<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Geminate Solutions</title>
    <description>The latest articles on DEV Community by Geminate Solutions (@geminate_solutions_9b6035).</description>
    <link>https://dev.to/geminate_solutions_9b6035</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3845246%2Fa42ad70a-16b6-4602-aaf9-cd64d38e4b30.jpg</url>
      <title>DEV Community: Geminate Solutions</title>
      <link>https://dev.to/geminate_solutions_9b6035</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/geminate_solutions_9b6035"/>
    <language>en</language>
    <item>
      <title>Legacy Application Modernization Without Forcing Password Resets</title>
      <dc:creator>Geminate Solutions</dc:creator>
      <pubDate>Mon, 26 Oct 2026 06:45:32 +0000</pubDate>
      <link>https://dev.to/geminate_solutions_9b6035/legacy-application-modernization-without-forcing-password-resets-570j</link>
      <guid>https://dev.to/geminate_solutions_9b6035/legacy-application-modernization-without-forcing-password-resets-570j</guid>
      <description>&lt;p&gt;You can move users to a new login system without making anyone reset their password. To do it, you either import their existing password hashes or check each password once against the old system the next time that user signs in. Most legacy application modernization projects use both methods, with a short period where the old and new identity providers run side by side.&lt;/p&gt;

&lt;p&gt;Picture your situation. Your user table is full of hashes from a framework nobody on the team chose. The board wants the new platform live without a wave of "I can't log in" tickets. This guide is written for that moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why can't you just copy the passwords across?
&lt;/h2&gt;

&lt;p&gt;You never had the passwords. You have hashes, and a hash only works with the exact algorithm, salt and settings that created it. The new identity provider can only check a password against an old hash if it understands that format.&lt;/p&gt;

&lt;p&gt;Modern providers such as Auth0, Okta, AWS Cognito, Keycloak and Microsoft Entra all handle bcrypt, PBKDF2, scrypt or Argon2 in some form, either natively or through an import hook. The trouble starts with homegrown schemes: unsalted MD5, SHA-1 with a global pepper kept in a config file, or a custom hashing loop someone wrote years ago. Those need custom verification code or a lazy migration.&lt;/p&gt;

&lt;p&gt;Then there is everything around the password. MFA secrets, social login links, "remember me" cookies, mobile refresh tokens and API keys all live in the old system too, and each one needs a plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does getting the login switch wrong cost you?
&lt;/h2&gt;

&lt;p&gt;The login page is the one screen every user touches.&lt;/p&gt;

&lt;p&gt;The failures are predictable. A user who signed up as "&lt;a href="mailto:John@Company.com"&gt;John@Company.com&lt;/a&gt;" and later as "&lt;a href="mailto:john@company.com"&gt;john@company.com&lt;/a&gt;" becomes two accounts. Mobile apps holding old refresh tokens log everyone out at once. MFA users are asked to enrol again with no warning and assume they have been hacked. A stricter password policy kicks in at first login and blocks people halfway through a task.&lt;/p&gt;

&lt;p&gt;Each of those turns into support tickets, paying customers losing their sessions, and a rollback conversation nobody wants. A rushed import can also leave weak legacy hashes sitting in your new provider for years. That turns old security debt into permanent security debt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option one: bulk hash import
&lt;/h2&gt;

&lt;p&gt;You export every user record with its hash, salt and algorithm settings, then load them all into the new provider at once. On cutover day, users sign in with their existing password and notice nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it fits:&lt;/strong&gt; the old hashes use a standard algorithm the new provider supports, and you want a clean cutover date.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Risks to manage:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Weak hashes come along for the ride.&lt;/strong&gt; If the legacy store uses MD5 or SHA-1, have the team wrap each one during export (for example, bcrypt applied over the old hash) so nothing weak is stored at rest. Then upgrade to a native hash at each user's next login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data drift between export and cutover.&lt;/strong&gt; Anyone who changes their password after the export ends up with a stale hash. Plan a final delta sync during a short change freeze.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity collisions.&lt;/strong&gt; Normalise emails and resolve duplicates before the import, not after.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Option two: rehash each user at their next login
&lt;/h2&gt;

&lt;p&gt;This is also called lazy or just-in-time migration. When the new provider gets a login from a user it doesn't recognise, it calls a small piece of code that checks the password against the old system. If the password matches, it creates the user with a fresh, modern hash. The Cognito user migration trigger and Auth0 custom database scripts are built for this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it fits:&lt;/strong&gt; the hash format is unusual, you can't export the hashes safely, or you want every stored password upgraded to a modern algorithm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Risks to manage:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The long tail.&lt;/strong&gt; Active users move over quickly. Dormant users may never log in, so the old system has to stay reachable until you decide what happens to them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The new system now depends on the old one.&lt;/strong&gt; If the legacy database is slow or down, logins fail. Require a strict timeout and a clear error message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brute force exposure.&lt;/strong&gt; The migration code path needs the same rate limiting and lockout rules as the main login.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plan for the stragglers from day one, not in month six.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option three: run old and new identity providers side by side
&lt;/h2&gt;

&lt;p&gt;Both systems stay live for a fixed window. A routing layer decides which provider handles each login, usually based on a "migrated" flag on the user. New features go on the new provider, and users move across in waves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it fits:&lt;/strong&gt; you have enterprise customers with their own SAML or OIDC connections, or several apps share one login and can't all move on the same day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Risks to manage:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two sources of truth.&lt;/strong&gt; Profile edits, deactivations and password changes must sync both ways. Otherwise a deactivated employee keeps access through the old door.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session confusion.&lt;/strong&gt; Every app must accept tokens from both issuers during the window, then stop accepting old tokens on a set date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The window never closes.&lt;/strong&gt; If the plan has no end date, you end up paying for and securing two systems indefinitely.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Which approach should you choose?
&lt;/h2&gt;

&lt;p&gt;Use these rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If the hashes are standard and most users are active,&lt;/strong&gt; do a bulk import with a final delta sync.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If the hashes are weak or custom,&lt;/strong&gt; use lazy migration with a fixed end date. After that date, send remaining users a friendly reset email.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you have enterprise SSO connections or several apps,&lt;/strong&gt; run both providers side by side and migrate tenant by tenant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you aren't sure,&lt;/strong&gt; combine them. Bulk import the users you can, lazy migrate the rest, and keep the old provider read-only as a fallback.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trade-off is simple. Bulk import is fast but carries old weaknesses forward. Lazy migration is cleaner but slow to finish. Side by side is the most flexible and needs the most discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who doesn't need any of this?
&lt;/h2&gt;

&lt;p&gt;Some teams can skip all of it. Say your app is an internal tool used by one office, and everyone can walk over to IT. Then a planned reset with a clear email and a help desk on standby is the honest, cheap answer. Don't build a migration pipeline for a problem one well-written email solves.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you require from your team before the switch?
&lt;/h2&gt;

&lt;p&gt;Get these in writing before anyone touches production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A hash inventory.&lt;/strong&gt; List every algorithm in the user table with a count of users on each. Older apps often hold several generations of hashing, and one unknown format can break a bulk import.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An identity cleanup report.&lt;/strong&gt; Cover duplicates, case clashes and missing emails. Collisions cause most "wrong account" tickets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An MFA and session plan.&lt;/strong&gt; Can TOTP secrets be exported? What happens to existing mobile sessions? Surprise logouts and forced MFA re-enrolment drive most support volume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rollback path.&lt;/strong&gt; Know how to point logins back at the old system within minutes. You want that decided calmly, not during an incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A shared dashboard.&lt;/strong&gt; Show login success rate, failure reasons and migration progress, and make it visible to support. That way the team spots a spike before the inbox does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dated end state.&lt;/strong&gt; Agree when the old provider gets switched off and what dormant users will receive. This stops the side-by-side window from becoming permanent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One question tests any vendor quickly: what happens to a user with MFA turned on who hasn't logged in for two years? A good team answers right away. A weak one starts guessing.&lt;/p&gt;

&lt;p&gt;Authentication is usually one part of a larger modernisation. For how to sequence the rest without breaking what users rely on, read this &lt;a href="https://geminatesolutions.com/blog/legacy-system-modernization?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=2026-10-26-d2" rel="noopener noreferrer"&gt;legacy system modernization guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you'd like a second opinion on your login migration plan, ask Geminate Solutions for a free review.&lt;/p&gt;

</description>
      <category>legacy</category>
      <category>authentication</category>
      <category>security</category>
      <category>architecture</category>
    </item>
    <item>
      <title>ERP Development: Sync Your App and ERP With a Postgres Outbox</title>
      <dc:creator>Geminate Solutions</dc:creator>
      <pubDate>Sun, 25 Oct 2026 18:44:11 +0000</pubDate>
      <link>https://dev.to/geminate_solutions_9b6035/erp-development-sync-your-app-and-erp-with-a-postgres-outbox-4pe3</link>
      <guid>https://dev.to/geminate_solutions_9b6035/erp-development-sync-your-app-and-erp-with-a-postgres-outbox-4pe3</guid>
      <description>&lt;p&gt;If your custom app needs to send orders or invoices to an ERP, don't call the ERP API from inside your request handler. Write each change to an outbox table in the same Postgres transaction as the business data. Then let a separate Node.js worker push those rows to the ERP, with retries, backoff and a dead-letter queue. This is the part of ERP development that decides whether you can sync records between a custom app and your ERP without losing any or posting them twice.&lt;/p&gt;

&lt;p&gt;You may know the symptoms already. The ERP had a slow afternoon and some orders never arrived. Or the warehouse shipped one order and finance has two invoices for it. Nobody can say which records made it across without checking both systems by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you sync records between a custom app and your ERP without losing any?
&lt;/h2&gt;

&lt;p&gt;You make the database the single source of truth for what still needs to be sent. The outbox row and the order row commit together or not at all. A worker reads pending rows, sends them, and marks them done only after the ERP confirms.&lt;/p&gt;

&lt;p&gt;The usual approach is a "dual write": save the order, then call the ERP. It fails in three ways.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The call fails after your commit.&lt;/strong&gt; The order exists in your app and nowhere else. Nothing remembers to try again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The call succeeds but the response times out.&lt;/strong&gt; Your code thinks it failed and retries. The ERP now has two invoices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You make the call inside the transaction.&lt;/strong&gt; Now a slow ERP holds your database locks open, and its bad day becomes your app's bad day.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The outbox fixes the first and third problems by design. Idempotency fixes the second, which we cover below.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a broken sync actually cost you?
&lt;/h2&gt;

&lt;p&gt;The damage shows up downstream, in people's time. Customers get duplicate invoices and start doubting every one after that. Orders missing from the ERP never reach fulfilment, so they ship late or not at all.&lt;/p&gt;

&lt;p&gt;Stock levels drift because the ERP never saw some sales. At month end, finance compares exports line by line to find the gaps. Worst of all, these failures are quiet. You usually hear about them from a customer, weeks later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do you need an outbox at all?
&lt;/h2&gt;

&lt;p&gt;Not always. If you send a handful of records a day and someone checks them, or a nightly CSV import into the ERP already removes duplicates, keep it. An outbox adds a table, a worker and monitoring, and that is only worth it when records flow all day and people rely on them arriving.&lt;/p&gt;

&lt;p&gt;If orders come in all day, the ERP has outages or rate limits, or a duplicate invoice causes a real problem, read on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: create the outbox table
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;erp_outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;               &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;aggregate&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;aggregate_id&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event_type&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt;          &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;idempotency_key&lt;/span&gt;  &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;           &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;attempts&lt;/span&gt;         &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;next_attempt_at&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;last_error&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;       &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add an index so the worker's query stays fast as the table grows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;erp_outbox_pending&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;erp_outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next_attempt_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;status&lt;/code&gt; column has three values: &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;sent&lt;/code&gt; and &lt;code&gt;dead&lt;/code&gt;. Rows marked &lt;code&gt;dead&lt;/code&gt; are your dead-letter queue. They stay in the same table, where you can query them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: write the business row and the outbox row together
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BEGIN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO orders (customer_id, status) VALUES ($1, $2) RETURNING id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;confirmed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`INSERT INTO erp_outbox (aggregate, aggregate_id, event_type, payload)
       VALUES ('order', $1, 'order.created', $2)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;})]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;COMMIT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ROLLBACK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no network call here. The user gets a fast response whether or not the ERP is up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: how does the worker claim rows safely?
&lt;/h2&gt;

&lt;p&gt;It claims rows with &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; and pushes their &lt;code&gt;next_attempt_at&lt;/code&gt; forward as a lease. Two workers never take the same row. If a worker crashes halfway, its rows come back on their own once the lease runs out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;claimBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
    UPDATE erp_outbox SET next_attempt_at = now() + interval '2 minutes'
    WHERE id IN (
      SELECT id FROM erp_outbox
      WHERE status = 'pending' AND next_attempt_at &amp;lt;= now()
      ORDER BY id LIMIT 20
      FOR UPDATE SKIP LOCKED
    )
    RETURNING *`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lease has to be longer than your HTTP timeout. If it isn't, a slow call can be claimed a second time while the first one is still running.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do retries, backoff and the dead-letter queue work?
&lt;/h2&gt;

&lt;p&gt;Each failure increases the wait before the next try, with some randomness added so retries don't all land at once. Errors that retrying won't fix, and rows that have used up their attempts, go to &lt;code&gt;dead&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_ATTEMPTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;backoffMs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nx"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendToErp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ERP_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aggregate&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ERP_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`ERP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;retryable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendToErp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`UPDATE erp_outbox SET status = 'sent' WHERE id = $1`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dead&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;retryable&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX_ATTEMPTS&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`UPDATE erp_outbox
       SET attempts = $2, last_error = $3,
           status = CASE WHEN $4::boolean THEN 'dead' ELSE 'pending' END,
           next_attempt_at = now() + ($5 || ' milliseconds')::interval
       WHERE id = $1`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;dead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;backoffMs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;claimBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;processRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Network errors and timeouts have no &lt;code&gt;retryable&lt;/code&gt; flag, so they get retried. A 400 for a missing tax code goes straight to &lt;code&gt;dead&lt;/code&gt;, because sending the same bad payload eight more times won't fix it.&lt;/p&gt;

&lt;p&gt;To replay a dead row once the cause is fixed, set &lt;code&gt;status = 'pending'&lt;/code&gt; and &lt;code&gt;attempts = 0&lt;/code&gt;. Keep the same &lt;code&gt;idempotency_key&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you stop the ERP from posting an invoice twice?
&lt;/h2&gt;

&lt;p&gt;Give the ERP a key that stays the same on every retry, so a repeated request is recognised and ignored. The outbox's &lt;code&gt;idempotency_key&lt;/code&gt; is that key. How you pass it depends on what your ERP supports.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If the ERP accepts an idempotency header,&lt;/strong&gt; send it, as shown above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If it supports upsert by external ID,&lt;/strong&gt; use your outbox key or order number as that ID. NetSuite, for example, can upsert a record by its external ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If it supports neither,&lt;/strong&gt; store your reference in a custom field and look it up before you create the record. This is slower, but it is the only safe option.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more rule: if the order of events matters for a record, for example &lt;code&gt;order.updated&lt;/code&gt; must never arrive before &lt;code&gt;order.created&lt;/code&gt;, claim only the oldest pending row for each &lt;code&gt;aggregate_id&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you check before going live?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Alert when any row is dead.&lt;/strong&gt; Each dead row is an order or invoice the ERP doesn't know about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert on the age of the oldest pending row.&lt;/strong&gt; A stuck worker produces no errors, just silence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Save the ERP's response body when a call fails.&lt;/strong&gt; ERPs usually explain validation errors there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kill the worker in the middle of a send during testing.&lt;/strong&gt; That is exactly how duplicates happen, so prove your idempotency handles it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete old &lt;code&gt;sent&lt;/code&gt; rows on a schedule.&lt;/strong&gt; Otherwise the table grows forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're connecting your own app to an ERP and want a second opinion on the sync design before it goes live, Geminate Solutions can review your plan for free. You can see how we handle this kind of work in our &lt;a href="https://geminatesolutions.com/services/custom-development?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=2026-10-26-d1" rel="noopener noreferrer"&gt;custom development&lt;/a&gt; practice.&lt;/p&gt;

</description>
      <category>erp</category>
      <category>postgres</category>
      <category>node</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Application Modernization: Should an API Layer Come Before a Rewrite?</title>
      <dc:creator>Geminate Solutions</dc:creator>
      <pubDate>Sun, 25 Oct 2026 07:59:12 +0000</pubDate>
      <link>https://dev.to/geminate_solutions_9b6035/application-modernization-should-an-api-layer-come-before-a-rewrite-3m8l</link>
      <guid>https://dev.to/geminate_solutions_9b6035/application-modernization-should-an-api-layer-come-before-a-rewrite-3m8l</guid>
      <description>&lt;p&gt;Yes, in most cases you should put an API layer in front of your legacy system before you replace anything, as long as it has a clear boundary, contract tests and a date to retire it. Done that way, application modernization stops being one risky rewrite and becomes a series of small moves that your new web and mobile apps can ship on right now.&lt;/p&gt;

&lt;p&gt;You probably know the situation. There is a core system, maybe a customised ERP, a twenty-year-old Java or .NET monolith, or a database packed with stored procedures, and nobody wants to touch it. The people who wrote it have left. The business wants a customer app, a partner portal or a modern dashboard, and every plan seems to start with "first we rewrite the core".&lt;/p&gt;

&lt;p&gt;There is a better order.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does an API layer in front of a legacy system actually do?
&lt;/h2&gt;

&lt;p&gt;An API layer sits between your new applications and the old core. New apps talk only to the layer, the layer talks to the legacy system, and nothing new ever reaches into the old database directly.&lt;/p&gt;

&lt;p&gt;It comes in two shapes. A facade is a thin, clean interface: it exposes the operations you need, such as "get order" or "create customer", and hides the awkward calls behind them. An anti-corruption layer goes further. It translates the legacy model into the model your new apps should use, so a customer record split across four tables with cryptic status codes like &lt;code&gt;ST_7&lt;/code&gt; becomes one clear object with a status of "on hold".&lt;/p&gt;

&lt;p&gt;The second shape matters more than most teams expect. Without translation, your new mobile app quietly inherits every odd rule and naming choice of the old core. You have not modernised anything. You have moved the mess onto a newer screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does this let new apps ship while the core keeps running?
&lt;/h2&gt;

&lt;p&gt;Because the layer separates two timelines. The web and mobile teams build against a stable contract, while the old system keeps doing its job behind it, untouched.&lt;/p&gt;

&lt;p&gt;Once that contract exists, you can replace what sits behind any single endpoint later. Move "customer lookup" to a new service, point the layer at it, and the apps never notice. This is the strangler pattern, and the API layer is the piece that makes it possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  When does an API layer only hide the problem?
&lt;/h2&gt;

&lt;p&gt;A facade hides the problem when it becomes permanent and nobody owns the core behind it. Watch for these signs early:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The layer is a pass-through.&lt;/strong&gt; Every endpoint maps one to one onto a legacy call, with the same names and fields. That is a proxy, not a boundary, and it freezes the old model into every new app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business logic is creeping into the layer.&lt;/strong&gt; Discount rules, eligibility checks or validation get copied into the facade "just for now". You now have two sources of truth that will drift apart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writes go around it.&lt;/strong&gt; The new app reads through the API, but a batch job or a reporting tool still writes straight into the legacy database. The layer cannot protect data it never sees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nobody can name what moves first.&lt;/strong&gt; If there is no list of which capability leaves the legacy core first, the layer is just one more system to maintain forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost of getting this wrong is quiet on day one. It shows up a year later as two systems to patch, releases that still wait on the old core, outages you cannot trace because the failure happens three hops away, and a team that now fears two systems instead of one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who does not need an API layer?
&lt;/h2&gt;

&lt;p&gt;If your legacy system already exposes a reasonably clean, documented API and only one new app consumes it, you may not need another layer. Adding one just adds a network hop, a deployment and something else to monitor.&lt;/p&gt;

&lt;p&gt;The same goes for a core that is small, well understood and covered by tests. There, a careful staged replacement can be faster than building a boundary you will throw away in a few months. Be honest about which situation you are in before anyone starts building.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you require from the team building it?
&lt;/h2&gt;

&lt;p&gt;This is where these projects succeed or fail, and you can ask for every item in writing before work starts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Contract tests on both sides.&lt;/strong&gt; Each endpoint needs tests that pin down exactly what it accepts and returns, using a tool like Pact or schema checks against an OpenAPI spec. They run against the legacy-backed version today and against any replacement later. If both pass the same suite, you can swap the backend safely. Without them, every migration step is a guess.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A written read/write boundary.&lt;/strong&gt; Reads through the facade are low risk. Writes are where data gets corrupted. Ask for a list of every system that writes to the legacy data store, plus a rule that all new writes go through the layer. If an old job still writes directly, decide whether to route it through the layer or freeze it before anything else moves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Translation, not mirroring.&lt;/strong&gt; Ask to see the domain model the new apps will use. If it reads like the legacy table names, push back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Visibility from day one.&lt;/strong&gt; Every request should carry a correlation ID into the logs on both sides of the layer. Timeouts must be set on purpose, because slow legacy calls can blow through a default 30 second timeout and leave users staring at a spinner. When the core is down, the apps should get a clear error, not a hang.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. A retirement plan with an order.&lt;/strong&gt; You want a list of capabilities in the order they will move off the core, and a definition of "done" for each. If the team cannot produce this, you are paying for a permanent facade.&lt;/p&gt;

&lt;h2&gt;
  
  
  In what order should you do it?
&lt;/h2&gt;

&lt;p&gt;A simple sequence works for most teams:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Map who calls the core today and who writes to its data.&lt;/li&gt;
&lt;li&gt;Pick the one capability the new app needs most, usually a read such as order status or account details.&lt;/li&gt;
&lt;li&gt;Define the contract for it in the new domain language, then write the contract tests.&lt;/li&gt;
&lt;li&gt;Build the layer for that capability only, and ship the new app feature against it.&lt;/li&gt;
&lt;li&gt;Add writes one at a time, each behind its own tests and a way to switch back.&lt;/li&gt;
&lt;li&gt;When a capability has stable contracts, replace its backend and retire that part of the core.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Use simple decision rules along the way. If a capability changes often and blocks the business, move it off the core early. If it is stable and rarely touched, leave it behind the facade longer. If two capabilities share tangled data, move them together or not at all.&lt;/p&gt;

&lt;p&gt;The trade-off is plain: you accept an extra component for a while in exchange for shipping now and replacing the core in pieces you can test and reverse.&lt;/p&gt;

&lt;p&gt;For a wider view of when to wrap, rebuild or replace, the guide to &lt;a href="https://geminatesolutions.com/blog/legacy-system-modernization?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=2026-10-25-d2" rel="noopener noreferrer"&gt;legacy system modernization&lt;/a&gt; walks through each option and its risks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the next step?
&lt;/h2&gt;

&lt;p&gt;If you have a core system nobody wants to touch and a new app waiting on it, send your current architecture or modernisation plan to Geminate Solutions for a free review. You get an NDA before we talk and a reply within 24 hours.&lt;/p&gt;

</description>
      <category>legacy</category>
      <category>api</category>
      <category>architecture</category>
      <category>modernization</category>
    </item>
    <item>
      <title>Shopify App Creation: Webhook HMAC and GDPR Handlers in Node.js</title>
      <dc:creator>Geminate Solutions</dc:creator>
      <pubDate>Sun, 25 Oct 2026 06:15:14 +0000</pubDate>
      <link>https://dev.to/geminate_solutions_9b6035/shopify-app-creation-webhook-hmac-and-gdpr-handlers-in-nodejs-511f</link>
      <guid>https://dev.to/geminate_solutions_9b6035/shopify-app-creation-webhook-hmac-and-gdpr-handlers-in-nodejs-511f</guid>
      <description>&lt;p&gt;Shopify app creation feels finished the moment your app installs and loads in the admin. Then review sends it back because your webhook endpoint accepted a forged request, or your three mandatory GDPR compliance webhooks do nothing. This walkthrough fixes both in a Node.js custom app: verify the HMAC against the raw body, answer fast and process later, and write real handlers for &lt;code&gt;customers/data_request&lt;/code&gt;, &lt;code&gt;customers/redact&lt;/code&gt; and &lt;code&gt;shop/redact&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If your logs say "HMAC verification failed" while the secret is definitely correct, this is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does HMAC verification fail even with the right secret?
&lt;/h2&gt;

&lt;p&gt;Almost always because you are hashing the wrong bytes. Shopify signs the exact raw request body, and &lt;code&gt;express.json()&lt;/code&gt; has already parsed it into an object by the time your handler runs.&lt;/p&gt;

&lt;p&gt;Shopify computes an HMAC-SHA256 of the body with your app's client secret, base64-encodes it and sends it in the &lt;code&gt;X-Shopify-Hmac-Sha256&lt;/code&gt; header. If you hash &lt;code&gt;JSON.stringify(req.body)&lt;/code&gt; instead, spacing and unicode escaping can differ from the original. One changed byte gives a different signature.&lt;/p&gt;

&lt;p&gt;The fix is a raw body parser on the webhook route, registered before any JSON middleware.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// Webhooks need the raw bytes, so this must come before express.json()&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isValidShopifyHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hmacHeader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hmacHeader&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hmacHeader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter. &lt;code&gt;timingSafeEqual&lt;/code&gt; stops an attacker from guessing the signature byte by byte through response timing. The length check comes first because &lt;code&gt;timingSafeEqual&lt;/code&gt; throws on unequal lengths, and a thrown error turns a forged request into a 500 instead of a clean rejection.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes wrong if you skip this?
&lt;/h2&gt;

&lt;p&gt;Without verification, anyone who finds your URL can post a fake &lt;code&gt;shop/redact&lt;/code&gt; and make you wipe a live merchant's data. Or a fake &lt;code&gt;orders/create&lt;/code&gt; that kicks off fulfilment logic.&lt;/p&gt;

&lt;p&gt;Shopify's automated review checks this directly. It sends compliance webhooks with an invalid signature and expects a &lt;code&gt;401&lt;/code&gt;. Returning &lt;code&gt;200&lt;/code&gt; to everything, which plenty of tutorials show, fails the check.&lt;/p&gt;

&lt;p&gt;Slow responses cause a quieter failure.&lt;/p&gt;

&lt;p&gt;Shopify expects a reply within five seconds. Miss that repeatedly and deliveries are marked failed and retried, and the subscription can eventually be removed. Your app then stops receiving events, and you find out when a merchant asks why orders stopped syncing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do you need to write this yourself?
&lt;/h2&gt;

&lt;p&gt;Not always. If you built on Shopify's official React Router or Remix app template, &lt;code&gt;authenticate.webhook(request)&lt;/code&gt; already verifies the HMAC and returns a 401 on failure. Keep it and skip to the handler logic below.&lt;/p&gt;

&lt;p&gt;This walkthrough is for custom Express or Fastify servers, apps moved off older templates, and teams who want to know what the library does for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you answer quickly and process later?
&lt;/h2&gt;

&lt;p&gt;Verify, enqueue, acknowledge, then do the real work in a background worker. The request handler should do nothing slow: no Admin API calls, no emails, no bulk deletes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Queue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bullmq&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REDIS_HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6379&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;webhookQueue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shopify-webhooks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isValidShopifyHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Shopify-Hmac-Sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SHOPIFY_API_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;webhookQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Shopify-Topic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Shopify-Shop-Domain&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Shopify-Webhook-Id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;exponential&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;removeOnComplete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using &lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt; as the job ID gives you deduplication. Shopify delivers at least once, so the same event can arrive twice, and BullMQ ignores a job whose ID it still holds. That is why completed jobs are kept for a week rather than removed instantly. No Redis? A database table with a unique constraint on the webhook ID does the same job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What must the three GDPR compliance handlers actually do?
&lt;/h2&gt;

&lt;p&gt;Each topic maps to a privacy obligation, and review expects real behaviour, not an empty &lt;code&gt;200&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;customers/data_request&lt;/code&gt;: a customer asked the merchant for their data. The payload carries &lt;code&gt;customer.id&lt;/code&gt;, &lt;code&gt;customer.email&lt;/code&gt; and &lt;code&gt;orders_requested&lt;/code&gt;. Gather what your app stores about that customer and give it to the merchant. You have 30 days.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;customers/redact&lt;/code&gt;: delete or anonymise that customer's data, including the orders listed in &lt;code&gt;orders_to_redact&lt;/code&gt;. Shopify holds this request back if the customer ordered recently, so when it arrives, act on it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;shop/redact&lt;/code&gt;: sent about two days after a merchant uninstalls. Delete everything you hold for that shop, access tokens included.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Worker&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bullmq&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shopify-webhooks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;

  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;customers/data_request&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;customerId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customerRecord&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;shopifyCustomerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;customerId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data_request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="na"&gt;export&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;records&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ready_for_merchant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;notifyMerchant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data_request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;customers/redact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orderIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orders_to_redact&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$transaction&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customerRecord&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;shopifyCustomerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderNote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;shopifyOrderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;orderIds&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="p"&gt;])&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shop/redact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$transaction&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customerRecord&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;shop&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderNote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;shop&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;shop&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="p"&gt;])&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then register the topics in &lt;code&gt;shopify.app.toml&lt;/code&gt; and run &lt;code&gt;shopify app deploy&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="k"&gt;[&lt;/span&gt;&lt;span class="n"&gt;webhooks&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;api_version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"2026-10"&lt;/span&gt;

&lt;span class="k"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;webhooks&lt;/span&gt;&lt;span class="k"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subscriptions&lt;/span&gt;&lt;span class="k"&gt;]]&lt;/span&gt;
&lt;span class="n"&gt;compliance_topics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"customers/data_request"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"customers/redact"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"shop/redact"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;uri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"/webhooks"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What do most implementations miss before review?
&lt;/h2&gt;

&lt;p&gt;The handlers above delete rows in your main database. Customer data usually lives in more places than that. Run this checklist before you submit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Logs.&lt;/strong&gt; If you log full webhook payloads, customer emails stay in your log store long after the redact. Log the webhook ID and topic, not the body.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party tools.&lt;/strong&gt; Email, analytics and support tools your app syncs to need their own delete call inside the redact job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caches and search indexes.&lt;/strong&gt; A deleted customer who still shows up in a Redis cache or search index has not been redacted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rejection test.&lt;/strong&gt; Send a request with a made-up HMAC header using &lt;code&gt;curl&lt;/code&gt; and confirm you get &lt;code&gt;401&lt;/code&gt;, not &lt;code&gt;200&lt;/code&gt; or &lt;code&gt;500&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A delivery test.&lt;/strong&gt; Use &lt;code&gt;shopify app webhook trigger&lt;/code&gt; for each compliance topic and watch the job complete in your worker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule of thumb: if a merchant's customer asked "where is my data?", every answer your system could give should come back empty after the redact job runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go from here
&lt;/h2&gt;

&lt;p&gt;Webhooks are one piece of a production Shopify app, alongside billing, rate limits and data sync. For the wider picture, the &lt;a href="https://geminatesolutions.com/blog/ecommerce-development-guide?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=2026-10-25-d1" rel="noopener noreferrer"&gt;ecommerce development guide&lt;/a&gt; covers how these parts fit together.&lt;/p&gt;

&lt;p&gt;If you are close to submitting and want a second pair of eyes, Geminate Solutions offers a free review of your app or plan, with a reply within 24 hours.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>node</category>
      <category>javascript</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Software Acceptance Testing with Playwright Your Client Can Read</title>
      <dc:creator>Geminate Solutions</dc:creator>
      <pubDate>Sat, 24 Oct 2026 06:05:06 +0000</pubDate>
      <link>https://dev.to/geminate_solutions_9b6035/software-acceptance-testing-with-playwright-your-client-can-read-mmk</link>
      <guid>https://dev.to/geminate_solutions_9b6035/software-acceptance-testing-with-playwright-your-client-can-read-mmk</guid>
      <description>&lt;p&gt;Software acceptance testing works best when the person signing off can read the tests themselves. With Playwright, you get there by writing each acceptance criterion as a Given/When/Then step, attaching a screenshot to every step and handing over the HTML report as the sign-off record.&lt;/p&gt;

&lt;p&gt;Maybe you have sat in a sign-off call where the client asks "but does it actually do what we agreed?" while you scroll through a green CI log. This tutorial is for you. When you finish, every criterion from the spec will appear in the report in the same words, with pass or fail and a picture next to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do sign-off meetings stall even when every test passes?
&lt;/h2&gt;

&lt;p&gt;They stall because the tests and the criteria are written in two different languages. The spec says "a trial customer can upgrade to the paid plan from the billing page". The test says &lt;code&gt;await page.click('#btn-upg-2')&lt;/code&gt;. Nobody outside the dev team can connect the two.&lt;/p&gt;

&lt;p&gt;So the client does what any careful buyer would do. They click through the app themselves, find something that looks off, and the meeting turns into a bug triage session.&lt;/p&gt;

&lt;p&gt;The tests passed, but they proved nothing to the person who needed proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a vague acceptance process cost you?
&lt;/h2&gt;

&lt;p&gt;It costs you the sign-off date. Each round of "let me just check one more thing" pushes the release back. Every disputed criterion turns into an argument about what was agreed instead of a quick look at evidence.&lt;/p&gt;

&lt;p&gt;It also costs trust. When clients cannot see what was tested, they assume the worst and start testing everything by hand. That slows every release after this one too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who doesn't need this?
&lt;/h2&gt;

&lt;p&gt;Skip this if you are building an internal tool and the person signing off also writes the code. A short checklist in the pull request is enough. This approach pays off when someone outside the dev team has to say yes before the work counts as done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Write the criteria before the tests
&lt;/h2&gt;

&lt;p&gt;Turn each criterion into a numbered, testable sentence and agree it with the client before anyone writes code. A good criterion has one actor, one action and one visible outcome.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AC-04:&lt;/strong&gt; Given a customer on a trial plan, when they choose the Pro plan on the billing page and confirm, then the billing page shows "Pro" as their current plan.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reject anything that says "should work well" or "fast". Without a number, a criterion like that can never fail, so nobody can sign it off either.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you make each Playwright step read like the criterion?
&lt;/h2&gt;

&lt;p&gt;Wrap &lt;code&gt;test.step&lt;/code&gt; in three small helpers so the report says "Given", "When" and "Then" in plain words. Each helper also takes a screenshot, even when the step fails.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// acceptance.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;fullPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;given&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`Given &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;when&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`When &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;then&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`Then &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;finally&lt;/code&gt; block matters. Without it, a failing step throws before the screenshot is taken, and you lose the one picture the client most needs to see.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you set up test data the client can trust?
&lt;/h2&gt;

&lt;p&gt;Create the data for each test through an API and delete it afterwards. Never rely on a shared "test user" that someone edited by hand last week. Once that account drifts, criteria start failing for reasons nobody can explain.&lt;/p&gt;

&lt;p&gt;A Playwright fixture handles the setup and the cleanup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// fixtures.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Customer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;extend&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;trialCustomer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Customer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;trialCustomer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;use&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`ac-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;@example.test`&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Accept-Test-123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/test/customers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;trial&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/test/customers/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only staging and test environments should have the &lt;code&gt;/api/test/customers&lt;/code&gt; endpoint. Put it behind an environment flag so it can never reach production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a full criterion look like as a test?
&lt;/h2&gt;

&lt;p&gt;Put the criterion ID in the test title and the original sentence in an annotation. The HTML report shows both at the top of the test.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// billing.spec.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./fixtures&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;given&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;when&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;then&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./acceptance&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AC-04: A trial customer can upgrade to Pro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;trialCustomer&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;annotations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Criterion&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Given a customer on a trial plan, when they choose the Pro plan on the billing page and confirm, then the billing page shows "Pro" as their current plan.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;given&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a customer on a trial plan is signed in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trialCustomer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Password&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trialCustomer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sign in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Trial&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;they choose the Pro plan and confirm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/billing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Choose Pro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Confirm upgrade&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;the billing page shows Pro as the current plan&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByTestId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;current-plan&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toHaveText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getByRole&lt;/code&gt; and &lt;code&gt;getByLabel&lt;/code&gt; find elements by the words a person sees on screen, so even the code reads close to the criterion. Keep the "Then" step to assertions only. If it clicks anything, you are really testing two criteria.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you produce the report you hand over at sign-off?
&lt;/h2&gt;

&lt;p&gt;Turn on the HTML reporter with a fixed output folder and keep traces for failures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// playwright.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;outputFolder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acceptance-report&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;never&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}]],&lt;/span&gt;
  &lt;span class="na"&gt;use&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BASE_URL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;retain-on-failure&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;BASE_URL=https://staging.example.com npx playwright test&lt;/code&gt;, then zip the &lt;code&gt;acceptance-report&lt;/code&gt; folder. The client unzips it and runs &lt;code&gt;npx playwright show-report acceptance-report&lt;/code&gt;, or you host the folder on any static URL, because the report needs to be served over HTTP. Each test shows its AC number, the criterion sentence, every step with a pass or fail mark, and a screenshot under each step.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you check before sending the report?
&lt;/h2&gt;

&lt;p&gt;Run through this list first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every agreed criterion has a test.&lt;/strong&gt; Compare the AC numbers in the spec with the test titles. A missing criterion looks like something you hid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The run used the build being signed off.&lt;/strong&gt; Put the commit hash in the zip file name and in an annotation, so nobody argues later about which build was tested.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failures stay in.&lt;/strong&gt; If AC-09 fails, send it as failed with a note. A report where everything is green looks suspicious and invites a manual retest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No real customer data appears in screenshots.&lt;/strong&gt; Use fixture data only, with obvious test emails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flaky tests get fixed, not retried until they pass.&lt;/strong&gt; If a criterion needs retries to pass, the client will run into the same flakiness in real use.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where do you start with software acceptance testing on your next project?
&lt;/h2&gt;

&lt;p&gt;Start at your next sign-off. Pick the three criteria the client cares about most, write them as Given/When/Then tests with the helpers above, and send the report before the meeting instead of during it.&lt;/p&gt;

&lt;p&gt;If you want help setting this up, or want to know whether your current tests cover what you agreed with your client, Geminate Solutions offers a free review of your app and test plan and replies within 24 hours. Details are on the &lt;a href="https://geminatesolutions.com/services/software-testing-qa?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=2026-10-24-d1" rel="noopener noreferrer"&gt;software testing and QA page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>playwright</category>
      <category>qa</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Legacy Application Modernization: Freeze Features or Keep Shipping?</title>
      <dc:creator>Geminate Solutions</dc:creator>
      <pubDate>Fri, 23 Oct 2026 11:58:13 +0000</pubDate>
      <link>https://dev.to/geminate_solutions_9b6035/legacy-application-modernization-freeze-features-or-keep-shipping-8fl</link>
      <guid>https://dev.to/geminate_solutions_9b6035/legacy-application-modernization-freeze-features-or-keep-shipping-8fl</guid>
      <description>&lt;p&gt;Most legacy application modernization projects fail on two simple options: a full feature freeze, or shipping new work on both systems for as long as the rebuild takes. The rule that usually works is a short, dated freeze on the area being rebuilt. Agree in writing that new features go only on the new system once each part of it is live.&lt;/p&gt;

&lt;p&gt;If you are a CTO, you know this meeting. Sales has deals waiting on a feature. The old system gets more fragile with every release. Someone senior asks why the team can't just do both. This piece covers what each option costs in team time, risk and business pressure. It also shows how to get the business to agree the rule before anyone writes new code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the three options, in plain terms?
&lt;/h2&gt;

&lt;p&gt;There are three options, and each one moves the pain somewhere different. Pick based on where your organisation can take that pain.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Full feature freeze.&lt;/strong&gt; Nothing new ships on the old system until the new one replaces it. The team stays focused and the business waits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dual build.&lt;/strong&gt; New features ship on the old system and are also built, or later ported, on the new one. The business keeps moving and the team pays for every feature twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New features only on the new system.&lt;/strong&gt; The old system gets bug fixes, security patches and legal changes, nothing else. Any new capability is built on the new system, which goes live module by module next to the old one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does a full feature freeze feel safe but rarely hold?
&lt;/h2&gt;

&lt;p&gt;A freeze is easy to explain and engineers like it. It breaks because rebuilds almost always take longer than planned, and the business stops waiting long before the rebuild is finished.&lt;/p&gt;

&lt;p&gt;The delay usually comes from inside the old system. Ten years of code hides business rules nobody wrote down: a discount that only applies to one region, or a report finance relies on every month-end. Each one you find adds weeks.&lt;/p&gt;

&lt;p&gt;Then comes the first exception: one urgent feature for one important customer. Then a second one. Now you are running a dual build that nobody planned or budgeted for.&lt;/p&gt;

&lt;p&gt;The early warning sign is simple. If someone asks for an exception in the first few weeks, your freeze will not last, so change the plan now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a dual-build period cost your team?
&lt;/h2&gt;

&lt;p&gt;A dual build lets the business keep shipping, but every feature needs two specs, two builds and two rounds of testing. The visible cost is time. The hidden cost is that the two systems slowly drift apart.&lt;/p&gt;

&lt;p&gt;Drift is quiet. The old system rounds tax one way and the new one rounds it another way. A validation rule gets updated in one place and not the other. These gaps stay hidden until cutover, when real customer data runs through the new system and the numbers don't match.&lt;/p&gt;

&lt;p&gt;There is a people cost too. The engineers who know the old system best get pulled back to maintain it, so the rebuild is left with the people who understand the business least. That is the opposite of what you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is "new features only on the new system" the best default?
&lt;/h2&gt;

&lt;p&gt;For most mid-size systems, yes, if the new system can go live in slices. It forces you to ship real parts of the rebuild early, and it keeps engineers from building the same feature twice.&lt;/p&gt;

&lt;p&gt;It works best with the strangler approach. You put a routing layer in front of the old application and move one module at a time, such as billing, then scheduling, then reporting. Each module is switched over to the new system and its old code path is turned off.&lt;/p&gt;

&lt;p&gt;The cost is that the business may wait for a feature until its area has moved. You can reduce that wait by migrating the areas with the most demand first, even when they are not the easiest technically. The real engineering work is the overlap: shared login, shared data, and keeping records in sync while both systems run. Plan for that from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does it cost to get this decision wrong?
&lt;/h2&gt;

&lt;p&gt;The worst result is a rebuild that never finishes, where you run two systems indefinitely with a team split between them. You keep paying for both systems and never get the benefits of the new one.&lt;/p&gt;

&lt;p&gt;You also lose trust. A freeze that breaks after two months makes leadership doubt every date you give them afterwards. Data mismatches at cutover can mean wrong invoices, missing records or a rollback in front of customers. Good engineers get tired of building the same thing twice and leave.&lt;/p&gt;

&lt;p&gt;None of this comes from bad code. It comes from a rule that was never agreed, or was agreed vaguely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who can skip all of this?
&lt;/h2&gt;

&lt;p&gt;Some teams don't need a formal rule. If the old system is small enough to rebuild in a few weeks, or the product is in maintenance mode with almost no feature requests, just freeze it and move on.&lt;/p&gt;

&lt;p&gt;The same goes if your project is a hosting move rather than a rewrite. Moving an application to new infrastructure without changing its behaviour rarely needs a feature policy. A cutover plan and a rollback plan are usually enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you agree the rule with the business before the rebuild starts?
&lt;/h2&gt;

&lt;p&gt;Put the rule on paper and get it signed by the people who will push hardest against it. Usually that means the CEO, the head of product and the head of sales. Do this before work starts, not when the first exception request arrives.&lt;/p&gt;

&lt;p&gt;A sequence that works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;List the roadmap for the next two quarters&lt;/strong&gt;, with a named business owner for each item.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sort each item into one of three groups:&lt;/strong&gt; must ship on the old system (legal, contractual or tied to a fixed date), can wait, or can be built on the new system first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the exception process.&lt;/strong&gt; Name who can approve a change to the old system, what evidence they need, and how many weeks of rebuild delay the requester agrees to accept. Making that trade-off visible stops most casual requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put dates on everything.&lt;/strong&gt; The freeze needs an end date and a review checkpoint, not "until it's done".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Define "done" for each slice&lt;/strong&gt; as the old code path being switched off, not "the new version works".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Use these decision rules to choose:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If your situation is...&lt;/th&gt;
&lt;th&gt;Then choose...&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Short rebuild, light roadmap&lt;/td&gt;
&lt;td&gt;Full freeze with a fixed end date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;System splits into clear modules&lt;/td&gt;
&lt;td&gt;New features only on the new system, busiest areas first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contractual commitments on the old system, hard to split&lt;/td&gt;
&lt;td&gt;Short dual build with a hard cap, porting each feature in the same sprint&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What should you ask your team or vendor before committing?
&lt;/h2&gt;

&lt;p&gt;These questions show quickly whether a plan will survive contact with the business. Vague answers usually mean the rule hasn't been thought through.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which module goes first, and was it chosen for business demand or because it is technically easy?&lt;/li&gt;
&lt;li&gt;How will data stay consistent while both systems are live, and who checks it?&lt;/li&gt;
&lt;li&gt;Who is responsible for finding the undocumented business rules in the old code?&lt;/li&gt;
&lt;li&gt;What exactly happens when sales asks for an exception?&lt;/li&gt;
&lt;li&gt;On what date is each old code path turned off?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At Geminate Solutions, after 50+ products shipped, we have learned that the agreement matters as much as the architecture.&lt;/p&gt;

&lt;p&gt;For the full picture on approaches and sequencing, read our &lt;a href="https://geminatesolutions.com/blog/legacy-system-modernization?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=2026-10-23-d2" rel="noopener noreferrer"&gt;guide to legacy system modernization&lt;/a&gt;. If you want another pair of eyes on your plan, ask Geminate Solutions for a free review. We reply within 24 hours.&lt;/p&gt;

</description>
      <category>legacy</category>
      <category>modernization</category>
      <category>cto</category>
      <category>architecture</category>
    </item>
    <item>
      <title>eLearning Software Development: Accept LTI 1.3 Launches in Node.js</title>
      <dc:creator>Geminate Solutions</dc:creator>
      <pubDate>Fri, 23 Oct 2026 03:59:38 +0000</pubDate>
      <link>https://dev.to/geminate_solutions_9b6035/elearning-software-development-accept-lti-13-launches-in-nodejs-400d</link>
      <guid>https://dev.to/geminate_solutions_9b6035/elearning-software-development-accept-lti-13-launches-in-nodejs-400d</guid>
      <description>&lt;p&gt;In eLearning software development, getting your product to run inside Moodle or Canvas means accepting an LTI 1.3 launch. In Node.js, that takes three steps: answer an OIDC login request, verify a signed JWT against the platform's public keys, and turn the claims inside it into your own users and courses. The hard part is the handful of checks that stop one school's teacher from landing in another school's data.&lt;/p&gt;

&lt;p&gt;This walkthrough is for you if a district just asked "does it work with Canvas?" and your team is now reading the IMS spec.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens during an LTI 1.3 launch?
&lt;/h2&gt;

&lt;p&gt;A launch is an OpenID Connect login. The LMS is the identity provider and your app is the client. The LMS starts the flow, your app sends the browser back with a state and a nonce, and the LMS returns a signed &lt;code&gt;id_token&lt;/code&gt; through a form POST.&lt;/p&gt;

&lt;p&gt;In order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The LMS sends the browser to your login URL with &lt;code&gt;iss&lt;/code&gt;, &lt;code&gt;login_hint&lt;/code&gt;, &lt;code&gt;target_link_uri&lt;/code&gt; and usually &lt;code&gt;client_id&lt;/code&gt; and &lt;code&gt;lti_message_hint&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Your app stores a &lt;code&gt;state&lt;/code&gt; and a &lt;code&gt;nonce&lt;/code&gt;, then redirects to the platform's authorization endpoint.&lt;/li&gt;
&lt;li&gt;The platform POSTs &lt;code&gt;id_token&lt;/code&gt; and &lt;code&gt;state&lt;/code&gt; to your launch URL.&lt;/li&gt;
&lt;li&gt;You verify the token, run the LTI checks and start a session.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In Moodle, the endpoints are under &lt;code&gt;/mod/lti/&lt;/code&gt;: &lt;code&gt;auth.php&lt;/code&gt; handles authorization and &lt;code&gt;certs.php&lt;/code&gt; serves the key set. On Canvas cloud the issuer is &lt;code&gt;https://canvas.instructure.com&lt;/code&gt; and the endpoints are on &lt;code&gt;sso.canvaslms.com&lt;/code&gt;. Every Canvas cloud customer shares that one issuer, so &lt;code&gt;client_id&lt;/code&gt; is how you tell registrations apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a sloppy launch cost you?
&lt;/h2&gt;

&lt;p&gt;Most launch bugs fail silently. They let the wrong person in, or they quietly create duplicate accounts.&lt;/p&gt;

&lt;p&gt;Without a nonce check, someone who captures an &lt;code&gt;id_token&lt;/code&gt; can replay it and sign in as that teacher until the token expires. Without a deployment ID check, any Canvas account that installs your developer key gets in, including schools that never signed a contract. If you match users on email, accounts get mixed up when a school hides emails or a student changes theirs.&lt;/p&gt;

&lt;p&gt;Each of these ends up as a ticket from a school IT director, and that IT director has a say in your renewal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do you need to build this yourself?
&lt;/h2&gt;

&lt;p&gt;Not always. If you connect to a single Moodle site you control, a maintained library such as ltijs will get you there faster than writing it by hand.&lt;/p&gt;

&lt;p&gt;Write it yourself when you serve many institutions, own your user model or need to audit exactly what you trust. The code below uses Express, &lt;code&gt;jose&lt;/code&gt; and Redis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: handle the OIDC login request
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;cookieParser&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cookie-parser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Redis&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ioredis&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REDIS_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlencoded&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;extended&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;cookieParser&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="c1"&gt;// One entry per registration. Canvas cloud shares one issuer, so match on client_id too.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;platforms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
  &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://moodle.example.edu&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;moodle-client-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;authUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://moodle.example.edu/mod/lti/auth.php&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;jwksUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://moodle.example.edu/mod/lti/certs.php&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;deploymentIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}]&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;findPlatform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iss&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;platforms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issuer&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;iss&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/lti/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;findPlatform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iss&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unknown platform&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`lti:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;iss&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lti_state&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;httpOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;secure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sameSite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maxAge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;response_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;response_mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;form_post&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;redirect_uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://tool.example.com/lti/launch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;login_hint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;login_hint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lti_message_hint&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lti_message_hint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lti_message_hint&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here. First, the state cookie has to be &lt;code&gt;SameSite=None&lt;/code&gt; and &lt;code&gt;Secure&lt;/code&gt;. The launch arrives as a cross-site POST, and a default &lt;code&gt;Lax&lt;/code&gt; cookie is not sent back on it. Second, platforms can send the login as either GET or POST, so &lt;code&gt;app.all&lt;/code&gt; accepts both. If a platform leaves out &lt;code&gt;client_id&lt;/code&gt;, fall back to matching on the issuer alone, but only when you have exactly one registration for that issuer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you verify the id_token against the platform's JWKS?
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;createRemoteJWKSet&lt;/code&gt; from &lt;code&gt;jose&lt;/code&gt; and let &lt;code&gt;jwtVerify&lt;/code&gt; check the signature, issuer, audience and expiry. The key set is fetched once and cached. It is fetched again when a token arrives with a key ID it has not seen, so key rotation on the platform side needs no work from you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createRemoteJWKSet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jwtVerify&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jose&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keySets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keySet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;keySets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;keySets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;createRemoteJWKSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;keySets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LTI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://purl.imsglobal.org/spec/lti/claim/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/lti/launch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lti_state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;State mismatch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getdel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`lti:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Login expired or already used&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iss&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clientId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;findPlatform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iss&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;jwtVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;keySet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jwksUrl&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;clockTolerance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Nonce mismatch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;azp&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bad azp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;LTI&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;version&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1.3.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Wrong LTI version&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;LTI&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message_type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LtiResourceLinkRequest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Wrong message type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deploymentId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;LTI&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;deployment_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deploymentIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deploymentId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unknown deployment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c1"&gt;// mapping continues below&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Which checks does most sample code leave out?
&lt;/h2&gt;

&lt;p&gt;A valid signature only proves the LMS issued the token. It does not prove the token is fresh, that it belongs to this login, or that it comes from a deployment you agreed to serve. Each of those needs its own check.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nonce, used once.&lt;/strong&gt; Compare it with the stored value and delete the record in the same step. &lt;code&gt;GETDEL&lt;/code&gt; does both, so posting the same token a second time fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State tied to the browser.&lt;/strong&gt; The cookie and the form field must match. Otherwise an attacker can push their own launch into someone else's browser session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment ID allowlist.&lt;/strong&gt; One Canvas developer key can be installed in many sub-accounts, and each install gets its own &lt;code&gt;deployment_id&lt;/code&gt;. Accept only the ones you registered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;azp&lt;/code&gt; when &lt;code&gt;aud&lt;/code&gt; is an array.&lt;/strong&gt; If the audience has more than one value, &lt;code&gt;azp&lt;/code&gt; must equal your client ID.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;message_type&lt;/code&gt; check matters as well. Without it, a deep-linking message could reach your resource launch handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you map launch claims to your own users and courses?
&lt;/h2&gt;

&lt;p&gt;Identify users by &lt;code&gt;iss&lt;/code&gt; plus &lt;code&gt;sub&lt;/code&gt;. Identify courses by &lt;code&gt;iss&lt;/code&gt;, &lt;code&gt;deployment_id&lt;/code&gt; and the context &lt;code&gt;id&lt;/code&gt;. Names, emails and course titles are display data: update them on every launch and never use them as identifiers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// inside the launch handler, after the checks. `db` is your own data layer.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iss&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;LTI&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;context&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;course&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;courses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iss&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;deploymentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;contextId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;roles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;LTI&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;roles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isTeacher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sr"&gt;/#&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;Instructor|Administrator&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enrolments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;courseId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;course&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isTeacher&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;teacher&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;student&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;LTI&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;target_link_uri&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://tool.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bad target&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a teacher runs the same course at two schools, they get two course records. That is the correct result. Roles arrive as full URIs, so match on the suffix and not on the bare word &lt;code&gt;Instructor&lt;/code&gt;. Some launches come from outside a course and have no context claim, so skip the course step when it is missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you test against Moodle and Canvas before a school does?
&lt;/h2&gt;

&lt;p&gt;Run Moodle locally in Docker, register your app as an External tool and launch it from a course. Then do the same in a Canvas test instance.&lt;/p&gt;

&lt;p&gt;Run four tests on each platform: post the same launch twice (the second must fail), launch from a deployment you have not registered (expect a 403), launch once as a student and once as a teacher, and launch as a user with no email. Then try Safari. When your tool runs in an iframe, Safari's third-party cookie blocking can drop your state cookie. The fix is to open the tool in a new window or to support the LTI platform storage messages that Canvas offers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does the launch fit in eLearning software development?
&lt;/h2&gt;

&lt;p&gt;The launch only gets people in the door. Behind it come grade passback through Assignment and Grade Services, roster sync through Names and Role Provisioning, and the &lt;a href="https://geminatesolutions.com/blog/multi-tenant-edtech-architecture?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=2026-10-23-d1" rel="noopener noreferrer"&gt;tenant model&lt;/a&gt; that keeps each school's data separate. If your identity keys are right from day one, those later pieces stay simple.&lt;/p&gt;

&lt;p&gt;Geminate Solutions has built EdTech systems, including an EdTech platform with 250K+ daily active users. The LMS integration decisions made early tend to shape whatever comes after them.&lt;/p&gt;

&lt;p&gt;If you are planning bigger changes, read the &lt;a href="https://geminatesolutions.com/blog/edtech-platform-rewrite-guide?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=2026-10-23-d1" rel="noopener noreferrer"&gt;EdTech platform rewrite guide&lt;/a&gt;, or send Geminate Solutions your launch flow for a free review, with a reply within 24 hours.&lt;/p&gt;

</description>
      <category>lti</category>
      <category>node</category>
      <category>javascript</category>
      <category>edtech</category>
    </item>
    <item>
      <title>EHR Integration: Matching Patients Without Duplicates or Mix-Ups</title>
      <dc:creator>Geminate Solutions</dc:creator>
      <pubDate>Wed, 21 Oct 2026 18:54:11 +0000</pubDate>
      <link>https://dev.to/geminate_solutions_9b6035/ehr-integration-matching-patients-without-duplicates-or-mix-ups-411d</link>
      <guid>https://dev.to/geminate_solutions_9b6035/ehr-integration-matching-patients-without-duplicates-or-mix-ups-411d</guid>
      <description>&lt;p&gt;In EHR integration, the safe way to match patients is to rely first on the identifier the hospital's EHR already trusts. That is its own patient ID or MRN, tied to the system that issued it. Use exact rules when you have that ID. Use scored matching only for the cases where you don't, and send every uncertain result to a human instead of letting your system guess.&lt;/p&gt;

&lt;p&gt;You probably know the situation. Your product collects patient details at intake. The hospital has its own records in Epic, Oracle Health or something older. Somebody has to decide whether "Jon Smith, 07/03/1984" in your database is the same person as "Jonathan Smith, 03/07/1984" in theirs. If you decide wrong one way, you create a duplicate. If you decide wrong the other way, you attach one patient's results to someone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is patient matching hard when both systems have the data?
&lt;/h2&gt;

&lt;p&gt;The US has no national patient identifier, and the data people type in is messy. An MRN is only unique inside the facility that issued it. So an MRN from one hospital means nothing to the clinic down the road, and it means nothing to your app unless you store which system it came from.&lt;/p&gt;

&lt;p&gt;The usual ways matching fails are predictable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Day and month swapped&lt;/strong&gt; in a date of birth, especially when staff or patients use different date formats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name changes&lt;/strong&gt; after marriage or divorce, hyphenated surnames, nicknames, and first and middle names entered the wrong way round.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twins and family members&lt;/strong&gt; who share a surname, an address, a phone number and sometimes an insurance subscriber ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Newborns&lt;/strong&gt; registered as "Baby Girl Patel" and renamed days later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Junior and senior&lt;/strong&gt; with the same name at the same address.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these looks like a perfect match on some fields and a clear mismatch on others.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deterministic or probabilistic matching: which should you use?
&lt;/h2&gt;

&lt;p&gt;Use both, in that order. Deterministic matching links records only when specific fields agree exactly, for example the same MRN from the same issuing system. Probabilistic matching gives each field a score and adds them up.&lt;/p&gt;

&lt;p&gt;Deterministic rules are predictable and easy to explain to a hospital's compliance team. Their weakness is that a single typo means no match, so you get duplicates.&lt;/p&gt;

&lt;p&gt;Probabilistic matching, usually based on the Fellegi-Sunter model, handles messy data better. A matching rare surname counts for more than a matching "Smith". A matching date of birth counts for more than a matching sex. You then set two thresholds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Above the upper threshold:&lt;/strong&gt; link automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Below the lower threshold:&lt;/strong&gt; treat as a new or different patient.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In between:&lt;/strong&gt; send to human review.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the hospital supports the FHIR &lt;code&gt;Patient/$match&lt;/code&gt; operation, it will return candidates with a grade such as certain, probable or possible. Treat only "certain" as safe to link without a person checking. A "probable" result is still a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which identifiers should you rely on?
&lt;/h2&gt;

&lt;p&gt;Not all fields deserve the same trust. This is a sensible starting order:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Identifier&lt;/th&gt;
&lt;th&gt;Trust&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;EHR patient ID or MRN plus issuing system&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;It is the hospital's own key, but only within that system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Date of birth&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Stable, but watch for day and month swaps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legal first and last name&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Changes over a lifetime, and spellings vary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone and email&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Families share them, and numbers get recycled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Last four of SSN&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Useful when present, often missing, and sensitive to store&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sex&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Too few values to tell people apart, and it can change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Address&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;People move, and households share it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One field should never be enough by itself: the insurance member ID. Dependants often share the subscriber's number, and it changes when the employer switches plans.&lt;/p&gt;

&lt;p&gt;Once a link is confirmed, store the EHR's patient ID against your record. From then on, match on that ID and stop re-matching on demographics.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does it cost you to get this wrong?
&lt;/h2&gt;

&lt;p&gt;Duplicates are the mild failure. A patient's history gets split across two records, so a clinician may miss an allergy or order a test that was already done. Someone at the hospital then has to find and merge the records by hand.&lt;/p&gt;

&lt;p&gt;Overlays are the serious failure. An overlay is two different people merged into one record. Lab results, notes and medications go to the wrong person. That is a clinical safety problem and a privacy disclosure at the same time. Separating the records afterwards is slow, manual work, and some hospitals will switch off an interface until it has been cleaned up.&lt;/p&gt;

&lt;p&gt;Trust matters too. Hospital integration teams remember the vendor whose feed created a mess, and that memory comes back at contract renewal and in reference calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  When do you not need any of this?
&lt;/h2&gt;

&lt;p&gt;If your app runs inside the EHR through a SMART on FHIR launch, the EHR tells you which patient is open. There is nothing to match. The same goes for an app that only receives patients from the hospital's ADT feed with the MRN attached.&lt;/p&gt;

&lt;p&gt;In those setups, store the ID the EHR gives you and handle merge events properly. You only need matching logic when your system creates patients on its own, for example through self-registration, a referral form or a partner's data, and then has to connect them to hospital records.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should the human review queue work?
&lt;/h2&gt;

&lt;p&gt;The queue is where uncertain matches get settled, so it needs an owner, a clear screen and a record of every decision.&lt;/p&gt;

&lt;p&gt;Show both records side by side with the differing fields highlighted. Give the reviewer three choices: same person, different people, or needs more information. Save the decision, the reason and the reviewer's name.&lt;/p&gt;

&lt;p&gt;While a match is pending, hold the clinical data for that patient. Do not create a placeholder record that someone will have to merge later. Remember every "different people" decision so the same pair is not flagged again next week.&lt;/p&gt;

&lt;p&gt;Track how long items sit in the queue. A queue nobody works is worse than having no queue, because it hides data clinicians expect to see. Review the decisions every month and use them to adjust your thresholds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you require from the team before any patient data syncs?
&lt;/h2&gt;

&lt;p&gt;Before a single record moves, ask your team or vendor for these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A written matching policy, agreed with the hospital's health information management team.&lt;/strong&gt; They own record integrity, and they will have to live with your rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A test set of hard cases&lt;/strong&gt; (twins, name changes, swapped dates, newborns) built from synthetic or de-identified data, with the expected result written down for each one. Without it, nobody can tell whether a threshold works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thresholds set from test results, not guessed.&lt;/strong&gt; Guessed numbers drift towards whatever clears the backlog fastest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handling for EHR merges and unmerges&lt;/strong&gt;, through HL7 v2 ADT A40 messages or the FHIR &lt;code&gt;Patient.link&lt;/code&gt; "replaced-by" field. Hospitals merge records all the time, and your links have to follow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An audit trail for every link and unlink&lt;/strong&gt;, so you can answer when a record was linked, why, and by whom.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A way to unlink.&lt;/strong&gt; If a link cannot be reversed, an overlay becomes permanent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dry run period&lt;/strong&gt; where matching runs against the real feed without writing anything, so you can review the results first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring&lt;/strong&gt; of new duplicates, queue size and manual overrides, with alerts when any of them jumps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A good question for any vendor: "Show me what your system does when the EHR merges two patients we have already linked." A vague answer tells you what you need to know.&lt;/p&gt;

&lt;p&gt;If you are planning an integration and want a second opinion on your matching approach, our guide to &lt;a href="https://geminatesolutions.com/blog/healthcare-app-development?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=2026-10-22-d2" rel="noopener noreferrer"&gt;healthcare app development&lt;/a&gt; covers the wider build. You can also send your plan to Geminate Solutions for a free review. We sign an NDA before we talk and reply within 24 hours.&lt;/p&gt;

</description>
      <category>healthcare</category>
      <category>ehr</category>
      <category>interoperability</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Find underused EC2 instances and unattached EBS volumes with Node.js</title>
      <dc:creator>Geminate Solutions</dc:creator>
      <pubDate>Wed, 21 Oct 2026 18:40:00 +0000</pubDate>
      <link>https://dev.to/geminate_solutions_9b6035/find-underused-ec2-instances-and-unattached-ebs-volumes-with-nodejs-1m80</link>
      <guid>https://dev.to/geminate_solutions_9b6035/find-underused-ec2-instances-and-unattached-ebs-volumes-with-nodejs-1m80</guid>
      <description>&lt;p&gt;You can find underused EC2 instances and unattached EBS volumes with one Node.js script. It pulls 14 days of CloudWatch CPU and network metrics for every running instance, flags the ones that barely move, and lists every volume whose status is &lt;code&gt;available&lt;/code&gt;. That covers most of the quick wins in cloud optimization, and it only needs read-only permissions.&lt;/p&gt;

&lt;p&gt;This is for you if your AWS bill keeps growing while traffic stays flat and nobody can say for sure which servers still do real work. The script writes two CSV files that your team can go through in one meeting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do idle instances and orphaned volumes pile up?
&lt;/h2&gt;

&lt;p&gt;Creating resources is easy, and nobody is in charge of deleting them. Someone spins up a staging box for a demo. A load test leaves a fleet at its peak size. An Auto Scaling group gets replaced and its old instances stay behind.&lt;/p&gt;

&lt;p&gt;Volumes are worse. When you terminate an instance, only volumes with &lt;code&gt;DeleteOnTermination&lt;/code&gt; set to true are deleted with it. Data volumes attached later default to false, so they stay behind, detached and still billed for every provisioned GiB.&lt;/p&gt;

&lt;p&gt;Oversizing has a different cause. Teams pick a size at launch with room for a spike that never comes. Once the app works, nobody looks at the size again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does it cost you to leave this alone?
&lt;/h2&gt;

&lt;p&gt;The direct cost is paying every hour, with no end date, for capacity nobody uses. Four things drive it: instance size, instance count, hours running and provisioned GiB on every volume.&lt;/p&gt;

&lt;p&gt;The indirect cost is harder to see. An account full of unnamed resources with no owner slows every security review and hides real capacity problems. The next person who tries to clean up is afraid to touch anything, and with good reason.&lt;/p&gt;

&lt;p&gt;Delete the wrong volume and you can lose data you will never get back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who does not need this script?
&lt;/h2&gt;

&lt;p&gt;If you run a handful of instances in one region, use AWS Compute Optimizer and the rightsizing view in Cost Explorer. Both look at a similar 14 day window and need no code. Compute Optimizer only needs you to opt in.&lt;/p&gt;

&lt;p&gt;The script is worth it when you have several accounts or regions, want to set your own thresholds, or need a report you can run in CI every week and compare over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does the script decide what is idle or oversized?
&lt;/h2&gt;

&lt;p&gt;It pulls hourly data points for 14 days and applies a few simple rules. Fourteen days covers two full weeks, so a batch job that only runs on Sundays still shows up.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Suggested action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;IDLE&lt;/td&gt;
&lt;td&gt;p95 of hourly max CPU under 5% and under 50 MB of network a day&lt;/td&gt;
&lt;td&gt;Find the owner, then stop it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OVERSIZED&lt;/td&gt;
&lt;td&gt;Average CPU under 10% and p95 peak under 40%&lt;/td&gt;
&lt;td&gt;Test one size smaller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NO_DATA&lt;/td&gt;
&lt;td&gt;No CloudWatch data points&lt;/td&gt;
&lt;td&gt;Probably launched recently, check again later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;td&gt;Anything else&lt;/td&gt;
&lt;td&gt;Leave it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The script uses the 95th percentile of hourly peaks instead of the single highest value. That way one spike during a deploy does not hide a server that is otherwise idle.&lt;/p&gt;

&lt;p&gt;One limit you should know about: EC2 does not send memory metrics to CloudWatch by default. A database or cache with low CPU can still be using most of its RAM. If you run the CloudWatch agent, add &lt;code&gt;mem_used_percent&lt;/code&gt; from the &lt;code&gt;CWAgent&lt;/code&gt; namespace before you trust an OVERSIZED verdict on those machines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The script
&lt;/h2&gt;

&lt;p&gt;Install the two SDK clients:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i @aws-sdk/client-ec2 @aws-sdk/client-cloudwatch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The role running it needs only &lt;code&gt;ec2:DescribeInstances&lt;/code&gt;, &lt;code&gt;ec2:DescribeVolumes&lt;/code&gt; and &lt;code&gt;cloudwatch:GetMetricData&lt;/code&gt;. GetMetricData is billed per metric requested, so the cost of each run grows with your instance count (four metrics per instance). Run it weekly, not every minute.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// rightsize.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;EC2Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paginateDescribeInstances&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paginateDescribeVolumes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@aws-sdk/client-ec2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;CloudWatchClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;GetMetricDataCommand&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@aws-sdk/client-cloudwatch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;writeFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AWS_REGION&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;us-east-1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ec2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EC2Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CloudWatchClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;DAYS&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;listRunningInstances&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;paginateDescribeInstances&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ec2&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Filters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;instance-state-name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;running&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Reservations&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Instances&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Tags&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;Value&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
        &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;InstanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;InstanceType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/,/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;queriesFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;InstanceId&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;inst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;MetricStat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Metric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Namespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AWS/EC2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;MetricName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Dimensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dims&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;Period&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;Stat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;stat&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cpuavg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CPUUtilization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Average&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cpumax&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CPUUtilization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Maximum&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;netin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NetworkIn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sum&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;netout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NetworkOut&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sum&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;instances&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;instances&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;queries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;instances&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;inst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;queriesFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;NextToken&lt;/span&gt;
    &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GetMetricDataCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;StartTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;EndTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;MetricDataQueries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NextToken&lt;/span&gt;
      &lt;span class="p"&gt;}))&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MetricDataResults&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Values&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;NextToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NextToken&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;NextToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;listUnattachedVolumes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;paginateDescribeVolumes&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ec2&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Filters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;available&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Volumes&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VolumeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VolumeType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;sizeGiB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;ageDays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CreateTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;86400000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SnapshotId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;avg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;p95&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cpuAvg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cpuPeak&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;netMBPerDay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cpuAvg&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NO_DATA&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cpuPeak&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;netMBPerDay&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;IDLE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cpuAvg&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;cpuPeak&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OVERSIZED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;instances&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;listRunningInstances&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;metrics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;instances&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;instances&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;inst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cpuAvg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cpuavg_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cpuPeak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;p95&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cpumax_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;netBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;netin_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;netout_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;netMBPerDay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;netBytes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;DAYS&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;inst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cpuAvg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cpuPeak&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;netMBPerDay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cpuAvg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cpuPeak&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;netMBPerDay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;volumes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;listUnattachedVolumes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toCsv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;instances.csv&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;toCsv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;volumes.csv&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;toCsv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GetMetricData&lt;/code&gt; accepts up to 500 queries per call. The script sends 100 instances (400 queries) at a time and follows &lt;code&gt;NextToken&lt;/code&gt; until it has every data point. Run it with &lt;code&gt;AWS_REGION=eu-west-1 node rightsize.mjs&lt;/code&gt;, then repeat for each region you use.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you act on the report without breaking production?
&lt;/h2&gt;

&lt;p&gt;Go from the cheapest and safest change to the riskiest.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Unattached volumes older than 30 days.&lt;/strong&gt; Take a snapshot, tag it with the volume ID and the date, then delete the volume. A snapshot is billed on the data actually stored, not the provisioned size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IDLE instances.&lt;/strong&gt; If the instance has an owner tag, message the owner. If nobody claims it within a week, stop it but do not terminate it. A stopped instance keeps its volumes, so you can start it again if someone turns out to need it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OVERSIZED instances.&lt;/strong&gt; Move down one size in the same family, for example &lt;code&gt;m5.2xlarge&lt;/code&gt; to &lt;code&gt;m5.xlarge&lt;/code&gt;, during a quiet window. Watch latency and error rates for a few days before you go smaller again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These rules are worth writing down for the team:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If it is a burstable T family instance, check &lt;code&gt;CPUCreditBalance&lt;/code&gt; before you downsize. A smaller T instance earns credits more slowly, and an app that runs out of credits gets throttled.&lt;/li&gt;
&lt;li&gt;If it belongs to an Auto Scaling group, change the launch template, not the instance. If you change only the instance, the group replaces it at the old size.&lt;/li&gt;
&lt;li&gt;If CPU is low but the app is slow, the problem is somewhere else: disk, database or memory. Downsizing will make it worse.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What should you check before deleting a volume?
&lt;/h2&gt;

&lt;p&gt;Check three things. First, &lt;code&gt;ageDays&lt;/code&gt; counts from when the volume was created, not when it was detached. Look up the &lt;code&gt;DetachVolume&lt;/code&gt; event in CloudTrail to see how long it has really been unused. If CloudTrail shows nothing in its 90 day event history, the volume has been detached for longer than that.&lt;/p&gt;

&lt;p&gt;Second, read its tags for a project or owner. Third, if the &lt;code&gt;snapshot&lt;/code&gt; column is empty, nothing backs this volume up yet, so take a snapshot first.&lt;/p&gt;

&lt;p&gt;The cleanup is the easy part. Keeping the account clean takes tagging rules, named owners and sensible default sizes in your infrastructure code. If you want a second opinion on your AWS setup, Geminate Solutions offers a free review through its &lt;a href="https://geminatesolutions.com/services/cloud-devops?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=2026-10-22-d1" rel="noopener noreferrer"&gt;cloud and DevOps service&lt;/a&gt; and replies within 24 hours.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>node</category>
      <category>devops</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Online Assessment Platform Integrity: Server Controls vs Theatre</title>
      <dc:creator>Geminate Solutions</dc:creator>
      <pubDate>Wed, 21 Oct 2026 06:09:58 +0000</pubDate>
      <link>https://dev.to/geminate_solutions_9b6035/online-assessment-platform-integrity-server-controls-vs-theatre-2p27</link>
      <guid>https://dev.to/geminate_solutions_9b6035/online-assessment-platform-integrity-server-controls-vs-theatre-2p27</guid>
      <description>&lt;p&gt;Online assessment platform integrity depends on what your server controls, not on what the browser is told to block. Question sets issued by the server, per-student randomisation, server-side timing and answer-pattern checks hold up under pressure. Fullscreen locks, copy-paste blocking and tab-switch alerts mostly don't.&lt;/p&gt;

&lt;p&gt;Maybe a university partner, a certification body or your own board has asked you a direct question: how do we stop cheating? You have a lockdown ticket in the backlog, a proctoring vendor's deck in your inbox and limited engineering time. This article is about where that time should go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why can a student get around browser lockdowns?
&lt;/h2&gt;

&lt;p&gt;The browser runs on the student's machine, and the student controls that machine. Anyone can read, pause or override a check written in JavaScript by opening developer tools, installing an extension or running the exam inside a virtual machine.&lt;/p&gt;

&lt;p&gt;Tab-switch detection is the clearest example. It listens for focus and visibility events. A phone on the desk, a second laptop or a friend on a call never fires those events. So the control catches the student who opened another tab by accident and misses the one who planned ahead.&lt;/p&gt;

&lt;p&gt;Copy-paste blocking works the same way. It stops casual copying and does nothing against a phone camera.&lt;/p&gt;

&lt;p&gt;The bigger leak usually isn't about lockdown at all. It's the API response. Many platforms load the whole test into the page at the start, including correct answers or scoring keys, so the front end can grade instantly. Anyone who opens the network tab has the answer key. No lockdown fixes that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which anti-cheating controls actually hold?
&lt;/h2&gt;

&lt;p&gt;The ones the student can't touch. If the server decides what is shown, when time runs out and what counts as suspicious, there is nothing on the student's device worth tampering with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server-issued question sets.&lt;/strong&gt; The client asks for the next question and gets only that question, with no correct answer attached. The server stores the answer and grades it. Trade-off: this means more round trips, so an honest student on weak mobile data feels every slow request. You need retries and saved progress so a dropped request never loses an answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-student randomisation.&lt;/strong&gt; Draw each question from a pool, shuffle the options and, where it makes sense, change the numbers inside the question. A shared screenshot then helps far less. Trade-off: the versions have to be equally hard, or some honest students get a harder paper by chance. Store the random seed for every attempt so you can show any student exactly what they saw when they appeal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server-side timing.&lt;/strong&gt; The server records when the attempt started and rejects answers after the deadline. The countdown on screen is only a display. Trade-off: a dropped connection uses up real exam time. Set your policy in advance, for example a short grace period after reconnecting, and log every gap so support can judge cases fairly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Answer-pattern checks.&lt;/strong&gt; After each sitting, look for answers submitted faster than anyone could read the question. Look for groups of students who share the same unusual wrong answers, and for many attempts coming from one network or device. Shared wrong answers are a much stronger signal than shared right ones, because right answers are supposed to match. Trade-off: false positives. A flag should start a human review and never change a grade by itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does weak online assessment platform integrity cost you?
&lt;/h2&gt;

&lt;p&gt;A leaked answer key doesn't stay with one student. It spreads through a group chat during the first sitting, and by the second sitting the results tell you nothing. The institution voids the exam, students re-sit it, and your platform takes the blame.&lt;/p&gt;

&lt;p&gt;The opposite mistake hurts too. Aggressive lockdowns break screen readers and other assistive tools. They also flag honest students whose browsers lose focus for ordinary reasons, like a system notification. Each false flag turns into an appeal, a support ticket and a student who no longer trusts the result.&lt;/p&gt;

&lt;p&gt;Then there is load. With server-issued questions, every student calls your API in the same minute. If a whole cohort starts at 10:00, your integrity design is also a traffic design. Our guide to &lt;a href="https://geminatesolutions.com/blog/online-assessment-platform-exam-day-load?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=2026-10-21-d2" rel="noopener noreferrer"&gt;handling exam-day load on an assessment platform&lt;/a&gt; covers that side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do you need all of this?
&lt;/h2&gt;

&lt;p&gt;Not always.&lt;/p&gt;

&lt;p&gt;If your tests are practice quizzes, open-book by design or low-stakes, shuffle the options, keep answers off the client and stop there. The rest adds cost and friction, and honest students will feel it more than any cheater.&lt;/p&gt;

&lt;p&gt;The exception is an exam in a supervised room on devices you manage. There, a locked kiosk browser such as Safe Exam Browser does work, because you control the hardware as well as the software. On a student's own laptop, lockdown is theatre. On your machines, it's a real control.&lt;/p&gt;

&lt;h2&gt;
  
  
  In what order should you build these controls?
&lt;/h2&gt;

&lt;p&gt;Start with what leaks the most and costs the least to fix.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Audit what the client receives.&lt;/strong&gt; Open the network tab during a live test. If any response contains correct answers, scoring rules or the full question bank, fix that before anything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move timing to the server.&lt;/strong&gt; If the deadline exists only in the front end, a paused script or an edited request can extend it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add question pools and randomisation.&lt;/strong&gt; Do this next if the same exam runs in more than one time slot or for more than one cohort. If one group sits it once, at one time, it can wait.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log events and run pattern checks.&lt;/strong&gt; Record timing, answer changes, focus events and network data. Have a person review the flags after each sitting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consider lockdown or remote proctoring last.&lt;/strong&gt; Treat browser events as signals for review, not as verdicts. Add them only when the stakes justify the friction for honest students.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What should you ask a vendor or your own team?
&lt;/h2&gt;

&lt;p&gt;These questions separate real controls from a feature list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What does the API return when a question loads?&lt;/strong&gt; If the answer is in there, nothing else matters yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where is the deadline enforced?&lt;/strong&gt; The only acceptable answer is the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can you reproduce exactly what one student saw?&lt;/strong&gt; Without stored seeds, appeals turn into arguments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens to a student who loses connection for 90 seconds?&lt;/strong&gt; A vague answer means honest students will lose marks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who reviews a flag before it affects a grade?&lt;/strong&gt; If nobody does, expect appeals you can't defend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens when 20,000 students press start at once?&lt;/strong&gt; Integrity controls that fail at peak load protect nobody.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Geminate Solutions has built an exam platform with 10M+ requests per minute handled in production. That work taught one simple lesson: integrity and scale are the same design problem. Every control you move to the server adds another request at the busiest moment of the day.&lt;/p&gt;

&lt;p&gt;If that peak worries you, read our guide to &lt;a href="https://geminatesolutions.com/blog/edtech-login-storm-architecture?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=2026-10-21-d2" rel="noopener noreferrer"&gt;EdTech login storm architecture&lt;/a&gt;, or ask Geminate Solutions for a free review of your exam flow. You'll get a reply within 24 hours.&lt;/p&gt;

</description>
      <category>edtech</category>
      <category>security</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A GitHub Actions Security Gate for Node.js: Block Leaked Keys</title>
      <dc:creator>Geminate Solutions</dc:creator>
      <pubDate>Wed, 21 Oct 2026 05:00:34 +0000</pubDate>
      <link>https://dev.to/geminate_solutions_9b6035/a-github-actions-security-gate-for-nodejs-block-leaked-keys-55b2</link>
      <guid>https://dev.to/geminate_solutions_9b6035/a-github-actions-security-gate-for-nodejs-block-leaked-keys-55b2</guid>
      <description>&lt;p&gt;A GitHub Actions security gate for Node.js is a single workflow that runs on every pull request. It scans the new commits for leaked API keys, checks new dependencies for known vulnerabilities and confirms the lockfile is honest. If any of those checks finds something real, it blocks the merge. You can build it in an afternoon with free tools, and most of that afternoon goes into tuning, not setup.&lt;/p&gt;

&lt;p&gt;You may have found a live Stripe key in a commit from three weeks ago. Or you may have a Dependabot tab full of alerts that nobody has opened since spring. Either way, this walkthrough is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do leaked keys and vulnerable packages get past code review?
&lt;/h2&gt;

&lt;p&gt;Code review happens on diffs, and people reviewing a diff read the logic, not the config. A key pasted into a test fixture looks like noise. A risky transitive dependency hardly shows up at all. It sits somewhere in a 2,000-line lockfile change that nobody scrolls through.&lt;/p&gt;

&lt;p&gt;AI coding assistants make both problems worse. To get an example working, they hardcode credentials, and they add packages without a second thought. The code runs, the reviewer approves, and the key goes into history.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does it cost when one slips through?
&lt;/h2&gt;

&lt;p&gt;Deleting a leaked key in a later commit does not remove it. Git history keeps it, and so does every clone and fork. Bots scan public pushes for credential patterns all the time. The only real fix is rotation: you find every service and environment that uses the key, issue a new one and redeploy without breaking production.&lt;/p&gt;

&lt;p&gt;A vulnerable dependency costs you differently. You end up doing an emergency upgrade across major versions, under pressure, on code you didn't write.&lt;/p&gt;

&lt;p&gt;The hidden cost is a noisy gate. If the check fails on things that don't matter, people stop reading it. They re-run it, or an admin turns on bypass. Then the one real finding goes straight through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do you actually need a custom gate?
&lt;/h2&gt;

&lt;p&gt;Maybe not. A solo side project with no paying users and no production credentials may only need what GitHub already gives you: secret scanning with push protection and Dependabot alerts, both free on public repositories. Turn those on and stop there.&lt;/p&gt;

&lt;p&gt;You need the gate when several people merge code, when the app holds customer data or payment keys, or when part of the codebase came from an AI builder and nobody has reviewed it line by line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workflow
&lt;/h2&gt;

&lt;p&gt;Save this as &lt;code&gt;.github/workflows/security-gate.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;security-gate&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Scan PR commits for secrets&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;trufflesecurity/trufflehog@main&lt;/span&gt; &lt;span class="c1"&gt;# pin a release tag or SHA&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;extra_args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;--results=verified,unknown&lt;/span&gt;

  &lt;span class="na"&gt;dependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/dependency-review-action@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fail-on-severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&lt;/span&gt;
          &lt;span class="na"&gt;fail-on-scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;runtime&lt;/span&gt;

  &lt;span class="na"&gt;lockfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Lockfile matches package.json&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci --ignore-scripts&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Lockfile only pulls from the npm registry over https&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is what each job does and why it is set up this way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secrets.&lt;/strong&gt; TruffleHog scans only the commits between the PR base and head, so it stays fast. &lt;code&gt;fetch-depth: 0&lt;/code&gt; gives it that history. &lt;code&gt;--results=verified,unknown&lt;/code&gt; means it fails on keys it confirmed are live, plus keys it couldn't check. It ignores ones it proved are dead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dependencies.&lt;/strong&gt; Dependency review looks only at packages this PR adds or changes. That one choice keeps old debt from blocking unrelated work. &lt;code&gt;fail-on-scopes: runtime&lt;/code&gt; ignores dev-only tooling. On private repositories this action needs GitHub's paid code security add-on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lockfile.&lt;/strong&gt; &lt;code&gt;npm ci&lt;/code&gt; fails when &lt;code&gt;package.json&lt;/code&gt; and &lt;code&gt;package-lock.json&lt;/code&gt; disagree, which catches the classic "I edited package.json by hand" PR. &lt;code&gt;--ignore-scripts&lt;/code&gt; stops install scripts from running inside your gate. &lt;code&gt;lockfile-lint&lt;/code&gt; fails if a resolved URL points to an unknown host or plain http, which is a common way to sneak in a tampered package.&lt;/p&gt;

&lt;p&gt;If dependency review isn't available to you, swap that job for &lt;code&gt;npx --yes audit-ci@7 --config audit-ci.jsonc&lt;/code&gt; with a config like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"skip-dev"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"allowlist"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="c1"&gt;// Only reachable from an internal admin script. Review by 2026-12-01. Owner: backend lead&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"GHSA-xxxx-xxxx-xxxx"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do you tune it so the team doesn't learn to ignore it?
&lt;/h2&gt;

&lt;p&gt;Only fail on findings someone would actually fix today. Everything else goes into a report, not a red X.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start with verified secrets only.&lt;/strong&gt; Run TruffleHog with &lt;code&gt;--results=verified&lt;/code&gt; for the first two weeks, then add &lt;code&gt;unknown&lt;/code&gt;. The team's first experience should be a real catch, not ten false alarms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate on high and critical in runtime code.&lt;/strong&gt; Moderate issues in a test runner are real, but they are not merge blockers. Handle them in a scheduled job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run a nightly full audit separately.&lt;/strong&gt; A &lt;code&gt;schedule:&lt;/code&gt; workflow running &lt;code&gt;npm audit --omit=dev&lt;/code&gt; across the whole tree catches newly disclosed issues in old packages. It opens an issue instead of blocking someone's unrelated PR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every exception needs a reason, an owner and a review date.&lt;/strong&gt; An allowlist entry with no comment becomes permanent. The &lt;code&gt;allow-ghsas&lt;/code&gt; input on dependency review and the audit-ci allowlist both work for this. Read through them once a month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the gate under three minutes.&lt;/strong&gt; Slow checks get skipped. The three jobs run in parallel for that reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the failure tell people what to do.&lt;/strong&gt; Add a short note to your PR template: if the secrets job fails, rotate the key first, then remove it from the code. Removing it alone fixes nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you make sure nobody can skip it?
&lt;/h2&gt;

&lt;p&gt;In your repository settings, add a branch protection rule (or ruleset) on &lt;code&gt;main&lt;/code&gt; and mark &lt;code&gt;secrets&lt;/code&gt;, &lt;code&gt;dependencies&lt;/code&gt; and &lt;code&gt;lockfile&lt;/code&gt; as required status checks. Turn on "Do not allow bypassing" for admins too. A gate that the most senior person can quietly skip only trains everyone else to ask that person.&lt;/p&gt;

&lt;p&gt;One honest limit: this gate stops new problems. It doesn't find the key that was committed eight months ago. Run a full history scan once (&lt;code&gt;trufflehog git file://. --results=verified&lt;/code&gt;) and rotate whatever it finds before you trust the clean green checks.&lt;/p&gt;

&lt;p&gt;If your app started life in Lovable, Bolt, Cursor or a similar tool, the exposure is usually bigger than one pipeline. The &lt;a href="https://geminatesolutions.com/blog/vibe-coding-security?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=2026-10-21-d1" rel="noopener noreferrer"&gt;vibe coding security guide&lt;/a&gt; covers what to check beyond CI. Or send your repo or pipeline to Geminate Solutions for a free review, under NDA, with a reply within 24 hours.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>node</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>On-Prem to Cloud Migration: Running the Systems That Can't Move Yet</title>
      <dc:creator>Geminate Solutions</dc:creator>
      <pubDate>Tue, 20 Oct 2026 08:28:53 +0000</pubDate>
      <link>https://dev.to/geminate_solutions_9b6035/on-prem-to-cloud-migration-running-the-systems-that-cant-move-yet-20nf</link>
      <guid>https://dev.to/geminate_solutions_9b6035/on-prem-to-cloud-migration-running-the-systems-that-cant-move-yet-20nf</guid>
      <description>&lt;p&gt;If your on-prem to cloud migration plan assumes one clean cutover, expect that plan to change. Most migrations run hybrid for months or years. The real work is deciding how the systems left behind connect to the ones that moved, how people sign in to both, and who answers when something breaks in between.&lt;/p&gt;

&lt;p&gt;You probably know the situation. The customer portal and the new APIs already run in AWS or Azure. The ERP, the warehouse database, a licence server tied to a physical dongle and a nightly mainframe batch job are still in the server room. The migration plan has a slide that says "phase 2", and nobody has a date for it.&lt;/p&gt;

&lt;p&gt;This piece covers the four decisions that make that period stable or painful: network connectivity, shared identity, latency, and ownership of failures that cross the boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do so many systems get left behind?
&lt;/h2&gt;

&lt;p&gt;Some systems stay behind because of contracts, not code. Common examples are a vendor licence tied to specific hardware, a regulator that wants data kept in a particular facility, or a support agreement that ends if you change where the software is hosted.&lt;/p&gt;

&lt;p&gt;Others stay because nobody fully understands them. Think of a 15-year-old application with hard-coded IP addresses, a shared database that six other tools read from directly, or a nightly file drop to a partner over SFTP. Moving those safely means untangling them first, and that takes longer than the migration schedule allowed.&lt;/p&gt;

&lt;p&gt;A hybrid state does not mean the planning failed. It is the normal shape of a migration. The real mistake is treating it as temporary and building it with temporary decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does it cost to get the hybrid period wrong?
&lt;/h2&gt;

&lt;p&gt;The cost rarely arrives as one big outage. It shows up as slow, confusing incidents that eat weeks of your team's time.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A cloud service calls an on-prem database over a VPN that was sized for admin traffic. Pages that used to load instantly now take several seconds, and the team spends weeks blaming the new code.&lt;/li&gt;
&lt;li&gt;Staff have two logins, one for the old domain and one for the cloud tenant. When someone leaves, only one gets switched off. Months later, that former employee can still reach the ERP.&lt;/li&gt;
&lt;li&gt;An order fails somewhere between the cloud checkout and the on-prem inventory system. The cloud team sees a timeout. The infrastructure team sees a healthy server. The customer gets no confirmation because nobody owns the gap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In each case, nobody made the decision on purpose. You can make it now, before an incident forces it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When can you handle this without outside help?
&lt;/h2&gt;

&lt;p&gt;You may not need outside help if what's left on-prem is a few internal tools with light traffic and you already use one identity provider for single sign-on. Set up redundant VPN tunnels, name an owner for each flow, add link monitoring and look at it again in six months.&lt;/p&gt;

&lt;p&gt;Outside help is worth it when customer-facing traffic crosses the boundary, when user accounts live in two separate directories, or when the setup has been "temporary" for over a year and incidents keep landing in the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should the old and new environments connect?
&lt;/h2&gt;

&lt;p&gt;Choose the connection based on how much traffic crosses it and how much an outage would hurt. A site-to-site VPN over the internet is quick to set up and fine for admin access, light API traffic and batch jobs. A dedicated link such as AWS Direct Connect, Azure ExpressRoute or Google Cloud Interconnect gives steadier speed and capacity, but the carrier can take weeks to set it up.&lt;/p&gt;

&lt;p&gt;Decision rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If customer-facing requests cross the boundary on every page load, plan for a dedicated link with a VPN as backup.&lt;/li&gt;
&lt;li&gt;If only nightly jobs and admin tools cross it, a pair of redundant VPN tunnels is usually enough.&lt;/li&gt;
&lt;li&gt;If you can't tell yet, measure first. Log every cross-boundary call for two weeks, then choose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two details get missed often. The first is overlapping IP address ranges between the office network and the cloud network, so plan addresses before you build because renumbering later is miserable. The second is DNS, the system that turns server names into addresses. Cloud services need to find on-prem servers by name, and on-prem servers need to find private cloud services. Set up forwarding in both directions on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you run one identity system or two?
&lt;/h2&gt;

&lt;p&gt;Run one. Staff should sign in once and reach both the old and new systems. When someone leaves, a single switch-off should remove their access everywhere.&lt;/p&gt;

&lt;p&gt;In practice, that usually means keeping Active Directory on-prem as the master list of users for now, syncing it to Microsoft Entra ID or connecting it to Okta, and putting cloud apps behind single sign-on using SAML or OIDC. Older on-prem apps that only understand LDAP or Kerberos keep pointing at Active Directory. Once most apps are in the cloud, you make the cloud directory the master list.&lt;/p&gt;

&lt;p&gt;System-to-system logins need the same care. If a cloud function reads the on-prem database, give it its own named account with only the access it needs. Keep its credentials in a secrets manager, never as a shared admin password copied into a config file.&lt;/p&gt;

&lt;p&gt;If the answer to "how many places do I disable a leaver?" is anything other than one, fix identity before you migrate anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you handle latency between old and new systems?
&lt;/h2&gt;

&lt;p&gt;Assume every call across the boundary is slower and less reliable than a call inside one data centre. An app that made 200 small database queries per request was fine on a local network. With 20 to 40 milliseconds per round trip, those queries add up to seconds.&lt;/p&gt;

&lt;p&gt;Try these fixes in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep chatty systems together.&lt;/strong&gt; If an app and its database talk constantly, migrate them together or leave both on-prem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch and cache.&lt;/strong&gt; Replace one-row-at-a-time calls with bulk requests, and keep a copy of slow-changing reference data on the cloud side with a clear refresh rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a queue.&lt;/strong&gt; Put a queue between the two sides for anything that doesn't need an instant answer, such as order sync, invoice posting or stock updates. If the link drops, messages wait in line instead of failing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Give every cross-boundary call a deliberate timeout and retry rule. A 60 second default timeout on a request a customer is waiting for is a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who owns a failure that crosses the boundary?
&lt;/h2&gt;

&lt;p&gt;Name one person before go-live. Hybrid incidents drag on when two teams each prove their side is healthy while the fault sits between them.&lt;/p&gt;

&lt;p&gt;What closes the gap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One owner per flow, not per system.&lt;/strong&gt; For example, someone owns "checkout to inventory" from end to end, including the alert, the runbook and the fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One trace across both sides.&lt;/strong&gt; Give each cloud request a tracking ID, pass it into the on-prem logs, and send both sets of logs to one tool such as Datadog, Grafana or Elastic. Without that, someone ends up matching timestamps by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring for the link itself.&lt;/strong&gt; Track latency, dropped traffic and tunnel status, and give the link its own alerts like any other service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A runbook for when the link goes down.&lt;/strong&gt; Write down what slows down, what waits in a queue and what you tell customers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What order should you do this in?
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Map every flow that crosses the boundary, with its traffic volume and the teams that depend on it.&lt;/li&gt;
&lt;li&gt;Fix identity so one account and one offboarding step cover both sides.&lt;/li&gt;
&lt;li&gt;Choose connectivity from that map, and plan IP ranges and DNS.&lt;/li&gt;
&lt;li&gt;Redesign the chattiest flows by moving pairs together, batching, caching or queueing.&lt;/li&gt;
&lt;li&gt;Give every cross-boundary flow an owner, tracing and a runbook.&lt;/li&gt;
&lt;li&gt;Give each system that stayed behind an exit date and an exit condition, even if the date moves.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Without step 6, a hybrid setup can become permanent by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  A next step if you're in the middle of this
&lt;/h2&gt;

&lt;p&gt;Geminate Solutions has shipped 50+ products, including an exam platform handling 10M+ requests per minute in production. Much of that work comes down to making old and new systems run as one. If you'd like a second opinion on your hybrid setup, the &lt;a href="https://geminatesolutions.com/services/cloud-devops?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=2026-10-20-d2" rel="noopener noreferrer"&gt;cloud and DevOps team&lt;/a&gt; offers a free review of your migration plan under NDA, with a reply within 24 hours.&lt;/p&gt;

</description>
      <category>cloud</category>
      <category>devops</category>
      <category>migration</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
