<?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: Andrea Roversi</title>
    <description>The latest articles on DEV Community by Andrea Roversi (@androve2k).</description>
    <link>https://dev.to/androve2k</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%2F4006072%2F8e6fb2b9-126b-47aa-a022-18df714116cc.png</url>
      <title>DEV Community: Andrea Roversi</title>
      <link>https://dev.to/androve2k</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/androve2k"/>
    <language>en</language>
    <item>
      <title>PanelControl: a 65-file business app, in vanilla JavaScript with no framework</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Mon, 20 Jul 2026 08:44:50 +0000</pubDate>
      <link>https://dev.to/androve2k/panelcontrol-a-65-file-business-app-in-vanilla-javascript-with-no-framework-1618</link>
      <guid>https://dev.to/androve2k/panelcontrol-a-65-file-business-app-in-vanilla-javascript-with-no-framework-1618</guid>
      <description>&lt;p&gt;PanelControl is an internal business app I designed and maintain solo for an official myPOS reseller: sales, onboarding, shipping, staff shifts, HR, and administration, all in one multi-role Progressive Web App. It started as a single HTML file. It's now 65+ source files and about thirty serverless functions — and it's still framework-free, with no build step.&lt;/p&gt;

&lt;p&gt;Here's what kept it standing as it grew.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Before PanelControl, the business ran on spreadsheets, WhatsApp, email, and phone calls. Dozens of orders a day, hundreds of sales leads, contract activations, shipments, shifts, time off, payroll, plus a steady stream of leads from an external CRM — no single view, no reliable history, no way to know in real time what a colleague was doing.&lt;/p&gt;

&lt;p&gt;I built a single PWA that centralizes all of it, installable on desktop and mobile, used daily on tablets and phones by the team.&lt;/p&gt;

&lt;h2&gt;
  
  
  A hand-written rendering engine
&lt;/h2&gt;

&lt;p&gt;No React, no Vue. A global state object holds every piece of application data. Every state change triggers a debounced &lt;code&gt;render()&lt;/code&gt; (80ms, to avoid cascading re-renders during closely spaced writes), plus a &lt;code&gt;renderNow()&lt;/code&gt; for cases that need an immediate synchronous update.&lt;/p&gt;

&lt;p&gt;The part I'm proudest of: a &lt;strong&gt;user-interaction guard&lt;/strong&gt;. Before every re-render, the engine checks whether focus is on an input field, and if so, postpones the render by a few seconds so a realtime update from Firebase never wipes out a form someone is filling in — with explicit exceptions for controls that must stay reactive (month selectors, checkboxes).&lt;/p&gt;

&lt;p&gt;Not every module loads on first launch either. Heavy modules load on-demand when the user navigates to that section, with a retry loop if the module hasn't arrived over the network yet — important since the app also runs on tablets over spotty mobile connections.&lt;/p&gt;

&lt;h2&gt;
  
  
  From &lt;code&gt;on('value')&lt;/code&gt; to granular listeners
&lt;/h2&gt;

&lt;p&gt;The single most impactful optimization in the project: migrating Firebase Realtime Database listeners from &lt;code&gt;on('value')&lt;/code&gt; to granular &lt;code&gt;child_added&lt;/code&gt; / &lt;code&gt;child_changed&lt;/code&gt; / &lt;code&gt;child_removed&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Before:&lt;/strong&gt; every single write to a growing node caused &lt;em&gt;every&lt;/em&gt; connected client to re-download the entire history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After:&lt;/strong&gt; only the changed delta gets transmitted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measured result:&lt;/strong&gt; an estimated 40–60 MB/day saved in Firebase bandwidth, with a direct impact on pay-as-you-go costs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same pattern applied to the activity log cut the initial page load from 200 to 50 records via &lt;code&gt;once('value')&lt;/code&gt;, followed by a &lt;code&gt;child_added&lt;/code&gt; listener for new events only — about 75% bandwidth saved on that section alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication without shared credentials
&lt;/h2&gt;

&lt;p&gt;The login system was rebuilt to eliminate shared Firebase credentials on the client. A Netlify Function verifies username and password with PBKDF2-SHA256 hashing, applies server-side rate limiting against brute force, and returns a server-minted Firebase Custom Token. The client exchanges it for an authenticated session, with permissions mapped via custom claims and checked in the database's security rules.&lt;/p&gt;

&lt;p&gt;For granular, per-operator permissions on top of the base role, I settled on one non-negotiable pattern for every access check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;access = legacy_hardcoded_list.includes(operator)
      OR hasPermission(operator, feature)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never the dynamic check alone. This avoids breaking access for operators not yet explicitly migrated to the new permission system — a rule I've applied across every permission change in the project since: new rules OR with the old ones, they never replace them outright.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few modules that raised real problems
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Internal chat.&lt;/strong&gt; A floating bubble UI hit the classic &lt;code&gt;position:fixed&lt;/code&gt; bug: it stops working correctly once an ancestor has a CSS &lt;code&gt;transform&lt;/code&gt; applied, which is common when nesting modals. Fixed by making the chat a direct sibling of the main container instead of a descendant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mail.&lt;/strong&gt; Polls the Gmail API instead of using push, with a denormalized schema split across two nodes to balance list speed against detail speed. Listeners here are always granular — the nodes hold potentially heavy email bodies, and &lt;code&gt;on('value')&lt;/code&gt; would redownload the whole mailbox on any tiny change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Call history.&lt;/strong&gt; A two-speed architecture: the current month stays on the Realtime Database with a capped listener, past months move to Firestore with block pagination. Searches always route to Firestore to avoid saturating the realtime database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CRM reconciliation.&lt;/strong&gt; A scheduled function checks each lead against the external CRM one call at a time (not a bulk dump) to stay within rate limits, rechecking recent leads every 24 hours and permanently skipping older ones once they've settled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fail-soft serverless functions
&lt;/h2&gt;

&lt;p&gt;Backend functions are pure ESM, deliberately dependency-free. A few shared patterns across every webhook integration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deduplication via a business key (order number), not a CRM-generated ID&lt;/li&gt;
&lt;li&gt;Every webhook logs to both RTDB and Firestore, for realtime visibility and historical queries&lt;/li&gt;
&lt;li&gt;Critical alerts go out via Telegram with per-category throttling&lt;/li&gt;
&lt;li&gt;Webhooks always respond HTTP 200, even on an internally handled error — so the external CRM doesn't retry the same request forever&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One operational constraint worth knowing: Netlify environment variables cap out at 4KB per function. Large credentials like private keys end up hardcoded in the function file instead, with a note for manual rotation, rather than blowing the limit and breaking the deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;"No framework" doesn't mean "no discipline." It means writing by hand the rules a framework would otherwise give you for free — a sensible debounce, a user-interaction guard, a non-destructive permission pattern — and sticking to them as the codebase grows from one file to sixty-five.&lt;/p&gt;

&lt;p&gt;I wrote a longer, more detailed version of this case study, plus related deep-dives on the Custom Token migration and a cross-app HMAC token, on my site: &lt;a href="https://roversia.it/blog-21-panelcontrol-gestionale-vanilla-js-firebase.html" rel="noopener noreferrer"&gt;roversia.it/blog-21&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>firebase</category>
      <category>webdev</category>
      <category>pwa</category>
    </item>
    <item>
      <title>Free Online Burraco, From Scratch: Rules, Wildcards, and a Three-Tier AI in Vanilla JavaScript</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Fri, 17 Jul 2026 06:27:46 +0000</pubDate>
      <link>https://dev.to/androve2k/free-online-burraco-from-scratch-rules-wildcards-and-a-three-tier-ai-in-vanilla-javascript-5fp</link>
      <guid>https://dev.to/androve2k/free-online-burraco-from-scratch-rules-wildcards-and-a-three-tier-ai-in-vanilla-javascript-5fp</guid>
      <description>&lt;p&gt;I wanted a real burraco game, playable right away against the computer, with no sign-up and none of the intrusive ads that clutter most free alternatives online. Here's how I wrote the validation for sets and runs with wildcards, pot and hand-closing logic, and a three-tier AI opponent — all with no framework, no game library, and no build step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why burraco, and why from scratch
&lt;/h2&gt;

&lt;p&gt;Burraco is among the most searched card games online in Italy, yet good free options are scarce: many sites require an account just to start, others push straight toward paid multiplayer, others are so full of banners the match becomes unreadable on mobile. I wanted the opposite — open the page, the computer shuffles, you play. Like the other games on my site, it's all vanilla JavaScript: no card library, no engine, just DOM, CSS, and game logic.&lt;/p&gt;

&lt;p&gt;The interesting part isn't the interface — cards, animations, layout are mechanical work — but the rules themselves. Burraco has a set of constraints that look simple until you have to turn them into code that must always work, including the edge cases: how many runs can form with two wildcards at the ends, when the pot can be opened, what happens if nobody closes and the deck runs out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validating sets and runs, wildcards included
&lt;/h2&gt;

&lt;p&gt;The two valid melds are &lt;strong&gt;sets&lt;/strong&gt; (3+ cards of the same rank) and &lt;strong&gt;runs&lt;/strong&gt; (3+ cards in sequence, same suit). 2s and jokers are wild and can replace any card in either. For a set the rule is straightforward: separate real cards from wilds, check that all real cards share the same rank, and require wilds not to outnumber real cards — otherwise the set would become "mostly wild," which classic rules don't allow.&lt;/p&gt;

&lt;p&gt;The run is trickier. I take the real cards of the same suit, compute the &lt;strong&gt;span&lt;/strong&gt; between the lowest and highest rank, and the internal "gaps" left to fill are the difference between the span and the number of real cards. If the available wilds can't cover those gaps, the run is invalid. If wilds are left over after filling internal gaps, they can extend the run past either end — but only if there's "room" toward the ace or the king, since the deck doesn't wrap around.&lt;/p&gt;

&lt;p&gt;The case that cost me the most time: a run starting at 9 and ending at king, with one leftover wild. That wild can only extend downward (9→8) because there's no room above the king. If you don't explicitly handle the asymmetry between "room below" and "room above" the span, the validator accepts impossible runs.&lt;/p&gt;

&lt;p&gt;The actual "burraco" — the meld that gives the game its name — is a run of at least 7 cards: &lt;strong&gt;clean&lt;/strong&gt; if made of real cards only, &lt;strong&gt;dirty&lt;/strong&gt; if it contains one or more wilds. It's also the condition required to close a hand: without at least one burraco on the table, melding your entire hand isn't enough to win.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pot: a rule that depends on history, not the present
&lt;/h2&gt;

&lt;p&gt;The pot is a stack of 11 cards that can be taken whole instead of drawing a single card from the deck. To open it, though, a player must have melded at least one set or one &lt;strong&gt;clean&lt;/strong&gt; run — no wilds. This is the rule that forced me to rethink the game state: you can't just look at what's on the table right now, because once the pot is open it stays open even if only dirty melds follow. You need a persistent flag per player — "has already opened" — set once, on the first clean meld, and never recalculated from scratch.&lt;/p&gt;

&lt;p&gt;Whoever takes the pot adds all 11 cards to their hand in one move: a huge advantage in terms of possible combinations, but also eleven extra cards to get rid of before closing. Deciding when it's worth taking is one of the most important calls even for a human opponent, which makes it a good lever for tuning how "aggressive" the AI plays.&lt;/p&gt;

&lt;h2&gt;
  
  
  A three-tier AI, no neural networks
&lt;/h2&gt;

&lt;p&gt;No machine learning: the opponent runs on different heuristics per difficulty, applied at three points in the turn — whether to take the pot, searching for possible melds, and choosing which card to discard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Easy.&lt;/strong&gt; Takes the pot only 12% of the times it could, looks exclusively for sets already sitting in hand (no run search at all), attempts a single meld per turn and then stops, and discards a random card — even a wild if it comes up, which is objectively a weak move but realistic for a beginner tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Medium.&lt;/strong&gt; Takes the pot 30% of the time and switches to a &lt;strong&gt;greedy&lt;/strong&gt; loop: looks for sets, then runs, then tries adding cards to melds already on the table, repeating until it finds something to play or hits a safety iteration cap. For the discard, it picks the highest-value non-wild card, to shed the risk of being stuck with heavy cards at the end of the hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hard.&lt;/strong&gt; Takes the pot 65% of the useful times, runs the same greedy loop as medium but also reuses leftover wilds to extend its own runs toward a burraco (7 cards), and crucially chooses its discard by reading the table: it computes which suits and ranks the human opponent is actually using in their melds, and avoids discarding cards they'd need, keeping wilds as a last resort.&lt;/p&gt;

&lt;p&gt;The result is an opponent that, on the same codebase, behaves noticeably differently just by changing a handful of numeric thresholds and adding a couple of targeted heuristics — without the complexity of a real search engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing a hand, scoring, and what gets saved
&lt;/h2&gt;

&lt;p&gt;A hand ends when a player melds their last card with no discard left, having already made at least one burraco — or in a stalemate if the deck runs out before anyone closes. Scoring sums the value of each player's melded cards and subtracts whatever's left in hand, with dedicated bonuses for clean and dirty burracos. Match history and total score are saved in &lt;code&gt;localStorage&lt;/code&gt;: no account, but also no syncing across devices — a choice consistent with the "open and play" goal, worth revisiting only if real multiplayer with a backend becomes necessary.&lt;/p&gt;

&lt;p&gt;I'd already used the same installable, zero-dependency PWA approach for &lt;a href="https://roversia.it/blog-17-pwa-gioco-breakout-canvas-audio-powerup.html" rel="noopener noreferrer"&gt;Neon Breakout&lt;/a&gt;: the nature of the problem changes — real-time physics and collisions on canvas there, discrete rule validation and heuristic AI here — but the underlying philosophy stays the same: a self-contained HTML file, installable as an app, with no build step.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do you validate a run of cards with wildcards in JavaScript?&lt;/strong&gt;&lt;br&gt;
Separate real cards from wilds, compute the span between the lowest and highest rank of the same suit, and check that internal gaps are covered by the available wilds. Leftover wilds can then extend the run past either end, as long as it stays within the ace-to-king range.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you code an AI opponent for a card game like burraco?&lt;/strong&gt;&lt;br&gt;
No neural networks or exhaustive search needed: different heuristics per difficulty tier, applied to pot-taking probability, a greedy search for possible melds, and discard choice based on what the opponent is using.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you play free online burraco without signing up?&lt;/strong&gt;&lt;br&gt;
Yes: open the page and play right away against the computer, no account needed. Match history and score are saved locally in the browser via localStorage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does the pot work in burraco, and why is it the trickiest rule to code?&lt;/strong&gt;&lt;br&gt;
It opens when a player melds their first set or first clean run, with no wilds. It's tricky because it depends on that player's meld history, not the current table state: you need a persistent flag set only once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should you write a card game in vanilla JavaScript or use a framework?&lt;/strong&gt;&lt;br&gt;
For a card game with a classic HTML/CSS interface and validation logic, a framework adds complexity with no real benefit. It only starts paying off with real-time multiplayer or complex shared state.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;You can play the finished game at &lt;a href="https://roversia.it/giochi/burraco" rel="noopener noreferrer"&gt;roversia.it/giochi/burraco&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>gamedev</category>
      <category>webdev</category>
      <category>pwa</category>
    </item>
    <item>
      <title>A Black Box Drawn Over a PDF Isn't Redaction — Here's How I Fixed It Client-Side</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Wed, 15 Jul 2026 13:11:10 +0000</pubDate>
      <link>https://dev.to/androve2k/a-black-box-drawn-over-a-pdf-isnt-redaction-heres-how-i-fixed-it-client-side-10hd</link>
      <guid>https://dev.to/androve2k/a-black-box-drawn-over-a-pdf-isnt-redaction-heres-how-i-fixed-it-client-side-10hd</guid>
      <description>&lt;p&gt;I wanted a complete PDF editor running entirely in the browser — page reordering, annotations, highlights, a drawn signature, watermark — on the same zero-upload principle as the rest of my site's tools. The interesting technical problem wasn't the interface. It was redaction.&lt;/p&gt;

&lt;p&gt;A black box drawn over text in a PDF covers it visually, but the text stays in the document underneath, selectable and copyable. I wanted to fix that properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive approach (and why it's wrong)
&lt;/h2&gt;

&lt;p&gt;Most "redact PDF" tools out there just draw a shape over the text:&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;// ❌ Just draws a black rectangle over existing content&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pdfDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pageIndex&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;drawRectangle&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="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rgb&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;0&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="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// The original text is still in the page's content stream:&lt;/span&gt;
&lt;span class="c1"&gt;// selectable, copyable, extractable with any PDF parser&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open the resulting PDF in any reader, select "all text on the page" (or just copy the black area itself), and the "hidden" content reappears in the clipboard. At the data level, nothing was removed — the PDF just gained a colored rectangle on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: rasterize, but only where it matters
&lt;/h2&gt;

&lt;p&gt;The only way to truly remove content while staying fully client-side, with no server-side content-stream rewriting engine, is to convert the page into an image after applying the blackout. A bitmap has no "text underneath" to extract — there's no text left, just pixels.&lt;/p&gt;

&lt;p&gt;The obvious trade-off: rasterizing bloats file size and kills text selectability. So the rule is to rasterize &lt;strong&gt;only pages that actually contain a redaction&lt;/strong&gt;, and leave every other page exactly as it is in the original.&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;// Simplified export logic&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;i&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;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;totalPages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&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;hasRedaction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;redactionsByPage&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;length&lt;/span&gt; &lt;span class="o"&gt;&amp;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;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;hasRedaction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ✅ No redaction: page copied intact, stays vector&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;copied&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;outputDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copyPages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourceDoc&lt;/span&gt;&lt;span class="p"&gt;,&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;outputDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;copied&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 🔥 Page with redactions: rasterized at high resolution&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bitmap&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;renderPageToCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pdfjsDoc&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;burnRedactionsIntoCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;redactionsByPage&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="nf"&gt;burnAnnotationsIntoCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;annotationsByPage&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jpg&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;outputDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embedJpg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toJPEG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.92&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;newPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;outputDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addPage&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="nx"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jpg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&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="na"&gt;y&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="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&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, rasterization happens at a higher scale (3x) than on-screen display, otherwise the "burned" pages look noticeably blurrier than the vector pages that stayed in the document. Second, on rasterized pages, non-redaction annotations (text, highlights, shapes, signature, watermark) get burned into the same bitmap too — mixing rendering types on the same page causes inconsistencies across different PDF readers.&lt;/p&gt;

&lt;p&gt;The result: a 20-page document with one redaction on page 12 produces a PDF where 19 pages stay light, vector, and text-selectable, and only page 12 is an image — with nothing extractable under the blackout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: canvas + coordinated overlay
&lt;/h2&gt;

&lt;p&gt;The rendering pattern reuses something I'd already built for a PDF form-filler tool: each page is a PDF.js rendering &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; plus an HTML overlay of the same size, where annotations live as elements positioned as a &lt;strong&gt;percentage of the current viewport&lt;/strong&gt;, not absolute pixels. That keeps them aligned with the underlying text at any zoom level — pixel-anchored annotations would drift on every resize.&lt;/p&gt;

&lt;p&gt;Rendering is also guarded by a per-page render token: whenever scale or content changes, a new token is generated, and an in-flight render that's no longer the latest one self-cancels instead of painting a stale frame over a fresh one. Without this, rapidly resizing the window during a heavy render produces visually broken overlapping frames — a classic async-canvas race condition.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;"Redaction" carries an implicit irreversibility requirement that a graphic overlay alone doesn't satisfy.&lt;/li&gt;
&lt;li&gt;Rasterize selectively, not the whole document — treating an entire PDF as "less trustworthy" because of one sensitive page is a bad trade-off.&lt;/li&gt;
&lt;li&gt;Anchor annotations to the viewport, not to screen pixels.&lt;/li&gt;
&lt;li&gt;A per-page render token avoids race conditions on async canvases during rapid resize/zoom.&lt;/li&gt;
&lt;li&gt;Rasterize at a higher scale than the on-screen display scale, or burned pages look noticeably worse than the vector ones next to them.&lt;/li&gt;
&lt;li&gt;Code written for one tool pays off on the next one — the rendering/overlay engine and drawn-signature logic came from an earlier PDF form-filler tool and saved a lot of time here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything runs client-side: no upload, no server processing, the file never leaves the browser.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>pdf</category>
      <category>showdev</category>
    </item>
    <item>
      <title>A plaintext Firebase password authenticated anyone who visited the site — here's how I fixed it without disconnecting anyone</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Fri, 10 Jul 2026 03:15:08 +0000</pubDate>
      <link>https://dev.to/androve2k/a-plaintext-firebase-password-authenticated-anyone-who-visited-the-site-heres-how-i-fixed-it-38je</link>
      <guid>https://dev.to/androve2k/a-plaintext-firebase-password-authenticated-anyone-who-visited-the-site-heres-how-i-fixed-it-38je</guid>
      <description>&lt;p&gt;While doing a routine hardening pass on an internal Firebase panel — codename &lt;strong&gt;PanelControl&lt;/strong&gt;, a management tool used daily by multiple operators with different roles — what was supposed to be "let's add a few Telegram alerts for suspicious activity" turned into discovering that the app's entire login system was just a UI filter.&lt;/p&gt;

&lt;p&gt;Anyone who opened the site already had, automatically, a Firebase identity with full read/write access to the database. Here's what happened, and how it got fixed in 5 phases without ever locking the team out mid-shift.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;PanelControl is a vanilla-JS internal panel backed by Firebase Realtime Database + Firestore. Operators log in with email/password, checked client-side against a database node, with a lockout after failed attempts. Nothing unusual so far.&lt;/p&gt;

&lt;p&gt;The original ask was narrow: add Telegram notifications for a handful of suspicious events — brute-force attempts, a never-before-seen device for an operator, an unauthorized attempt to reach the Admin section, DevTools opened during use. Pure alerting work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #1: the login button that always unlocks
&lt;/h2&gt;

&lt;p&gt;Before writing any alerting logic, a review of the existing Admin-area password check turned up this:&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;// ❌ The "|| true" makes the whole condition always truthy&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkAdminPwd&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;el&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="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="nf"&gt;unlockAdmin&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// runs regardless of what's typed, or nothing at all&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;A debug leftover that made it to production. Anyone who landed on the Admin password overlay got in by clicking "Log in" — password or not. Fixed by actually wiring the real permission check, plus a server-side-verified fallback in case the function were ever called directly from the console.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real discovery: a shared, hardcoded Firebase credential
&lt;/h2&gt;

&lt;p&gt;Looking at the Realtime Database Rules ahead of the alerting work surfaced something much bigger. The Rules restricted read/write to a single fixed &lt;code&gt;auth.uid&lt;/code&gt; — reasonable, until you check who actually gets that &lt;code&gt;uid&lt;/code&gt;. This ran unconditionally, for every visitor, before the login screen even appeared:&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;// ❌ Plaintext Firebase credentials, executed for EVERY visitor&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;_fbEmail&lt;/span&gt; &lt;span class="o"&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;[shared-account]&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;[domain]&lt;/span&gt;&lt;span class="dl"&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="s1"&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;var&lt;/span&gt; &lt;span class="nx"&gt;_fbPass&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[REDACTED]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;firebase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;signInWithEmailAndPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_fbEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_fbPass&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app's real login form — personal email, personal password, permission checks — protected nothing at the database level. It was a UI filter sitting on top of a database that was already wide open to anyone who simply loaded the page. No need to even read the source to get the credentials; the app authenticated itself with them automatically.&lt;/p&gt;

&lt;p&gt;This isn't a one-line patch. Credentials shipped in a public JS bundle can never be truly secret, and with one Firebase identity shared by every operator, the Rules have no way to tell an authorized operator apart from anyone who just copied those two lines into an HTTP client.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The real security perimeter of a Firebase app is its Rules, not the login form the browser shows.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The fix: a serverless login function + Custom Tokens with role claims
&lt;/h2&gt;

&lt;p&gt;The architecture moves identity verification server-side, where the Firebase service account is never exposed to the client:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The operator submits email + password to a Netlify Function&lt;/li&gt;
&lt;li&gt;The function verifies credentials against the operators node (PBKDF2 via Web Crypto API) using the service account — never in the client bundle&lt;/li&gt;
&lt;li&gt;If valid, it computes the role and signs a &lt;strong&gt;Custom Token&lt;/strong&gt; with the operator's real identity as a claim&lt;/li&gt;
&lt;li&gt;The client calls &lt;code&gt;signInWithCustomToken()&lt;/code&gt; instead of the old hardcoded login&lt;/li&gt;
&lt;li&gt;Database Rules check &lt;code&gt;auth.token.operatore&lt;/code&gt; / &lt;code&gt;auth.token.admin&lt;/code&gt; instead of a single fixed &lt;code&gt;uid&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Netlify Function — server-side only, never shipped to the client&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&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;handler&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="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;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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. Server-side rate limit: 5 attempts, then 60s lockout per email&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getAttempts&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;if &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="nx"&gt;locked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;locked&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;423&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Verify against the operators node (service account, never client-side)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;op&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;findOperatorByEmail&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="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="nx"&gt;op&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyPassword&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="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passwordHash&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="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;registerFailedAttempt&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;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&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="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Role claims computed server-side, unforgeable by the client&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;operatore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chiave&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;op&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;token&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;mintCustomToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`op_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chiave&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;claims&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&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="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;operatore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chiave&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;admin&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;admin&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;The key point: &lt;code&gt;admin&lt;/code&gt; and &lt;code&gt;operatore&lt;/code&gt; are decided exclusively server-side and signed into a JWT with the service account's private key. The client can't "become admin" by flipping an in-memory variable — the claim is checked by the database Rules on every single read/write, not just when the session opens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling it out in 5 phases
&lt;/h2&gt;

&lt;p&gt;Swapping the Firebase identity of an app used daily by a whole team isn't a one-shot change:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Isolated function&lt;/strong&gt; — deployed but not called from the client yet. Zero risk, testable directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client with fallback&lt;/strong&gt; — login tries the new function first, falls back to the old method if it doesn't respond.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verification on real accounts&lt;/strong&gt; — admin and non-admin operators from different departments, console open hunting for permission errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Removing the hardcoded credentials&lt;/strong&gt; — auto-login gone, the app waits for &lt;code&gt;onAuthStateChanged()&lt;/code&gt; instead, with session persistence synced to the "Remember me" checkbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deny-by-default Rules&lt;/strong&gt; — only once the Service Worker has propagated the new client to the whole team.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Snag #1: Rules need touching mid-migration, not at the end
&lt;/h2&gt;

&lt;p&gt;The original plan left the Rules untouched until the last phase. As soon as the client started authenticating with the Custom Token in phase 2, &lt;code&gt;permission_denied&lt;/code&gt; errors appeared on realtime chat listeners still open from before login.&lt;/p&gt;

&lt;p&gt;Reason: changing Firebase identity at runtime immediately invalidates the permissions of anything already open under the old identity. The old Rules only authorized the shared &lt;code&gt;uid&lt;/code&gt; — the new, perfectly valid Custom Token meant nothing to them.&lt;/p&gt;

&lt;p&gt;Fix: an additive &lt;code&gt;OR&lt;/code&gt;, safe to publish mid-workday because Firebase Rules aren't sessions — every read/write is re-evaluated in real time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&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;".read"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"auth != null &amp;amp;&amp;amp; (auth.uid === '[SHARED_UID]' || auth.token.operatore != null)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;".write"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth != null &amp;amp;&amp;amp; (auth.uid === '[SHARED_UID]' || auth.token.operatore != null)"&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;
  
  
  Snag #2: deny-by-default broke the whole app for non-admins
&lt;/h2&gt;

&lt;p&gt;Once the team was fully on the new client, the Rules moved to a genuinely restrictive model: root &lt;code&gt;false&lt;/code&gt; by default, granular per-role permissions per node.&lt;/p&gt;

&lt;p&gt;First test with a non-admin account: the app opened completely empty. No visible console errors. The cause: one node had been classified "admin read-only," but it was also one of the nodes the app reads in full &lt;strong&gt;before showing any screen at all&lt;/strong&gt;, regardless of role. Restricting it left every non-admin operator hanging on a read that would never resolve — no visible error, because that specific listener had no explicit error callback.&lt;/p&gt;

&lt;p&gt;A second node, holding operator profile data, exposed a structural limit of Firebase Rules: &lt;strong&gt;they don't partially filter a node.&lt;/strong&gt; If the client reads it all at once, permission is granted or denied for the entire content — you can't say "department and email yes, password hash no" within the same node without first splitting the data. Not something to improvise mid-debug, so that node was temporarily left open to all operators (no regression — it was already fully exposed under the old shared identity), with the split planned as follow-up work.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Before restricting a node to a specific role, always check whether it's part of the app's bootstrap path for &lt;em&gt;every&lt;/em&gt; user — not just the role that's "supposed" to see it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Cleaning up: delete the account, don't just rotate the password
&lt;/h2&gt;

&lt;p&gt;One loose end: the original shared Firebase account. Rotating its password isn't enough — nothing uses it anymore, and Firestore's rules (separate from Realtime Database) only checked &lt;code&gt;request.auth != null&lt;/code&gt;, without checking &lt;em&gt;which&lt;/em&gt; user. Anyone who still had the old credential could still authenticate and touch Firestore data. Cleanest fix: delete the account from Firebase Authentication entirely. An account nothing depends on doesn't need a stronger password — it needs to not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A UI-level login isn't security if the database Rules don't mirror it — worth verifying explicitly even when the login "looks" correct.&lt;/li&gt;
&lt;li&gt;Changing Firebase identity at runtime invalidates permissions on everything already open — update Rules additively as you go, not at the end.&lt;/li&gt;
&lt;li&gt;Firebase Rules can't partially filter a node — split sensitive data out before writing granular permissions.&lt;/li&gt;
&lt;li&gt;Before restricting a node, check if it's on the app's bootstrap path for everyone.&lt;/li&gt;
&lt;li&gt;A real rate limit lives server-side — a client-only counter resets on page reload.&lt;/li&gt;
&lt;li&gt;An unused account should be deleted, not renewed — if a second system doesn't check the same claim, an old credential is still an open door.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Full bilingual write-up (with the RTDB Rules diffs, the security-alert Telegram integration, and the complete phase-by-phase checklist) on my &lt;a href="https://roversia.it/blog-18-firebase-custom-token-migrazione-rtdb-rules.html" rel="noopener noreferrer"&gt;blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>firebase</category>
      <category>security</category>
      <category>netlify</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building a Breakout PWA from scratch: canvas, synthesized audio, and power-ups in one HTML file</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Tue, 07 Jul 2026 07:37:38 +0000</pubDate>
      <link>https://dev.to/androve2k/building-a-breakout-pwa-from-scratch-canvas-synthesized-audio-and-power-ups-in-one-html-file-1jkh</link>
      <guid>https://dev.to/androve2k/building-a-breakout-pwa-from-scratch-canvas-synthesized-audio-and-power-ups-in-one-html-file-1jkh</guid>
      <description>&lt;p&gt;I wanted a small arcade game, installable as an app, with no dependencies and no build step. Here's how a complete Breakout came together — from a minimal skeleton to 15 hand-drawn levels, sound effects generated with the Web Audio API without downloading a single audio file, particles, screen shake, and power-ups balanced so the playfield doesn't get overcrowded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack first, game second
&lt;/h2&gt;

&lt;p&gt;The first question wasn't "which game" but "with what". For a simple or medium 2D game the practical options are few:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Native canvas + vanilla JavaScript&lt;/strong&gt; — maximum control, zero dependencies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phaser.js&lt;/strong&gt; — a full framework with physics included, useful if you want to save time on collisions and sprites&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain HTML/CSS&lt;/strong&gt; — for games with complex UI (cards, puzzles, quizzes), often with no game engine needed at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Since the rest of the site is already built as single-file HTML pages with no build step, the natural choice was to stay consistent: native canvas, one self-contained file, easy to understand and deploy.&lt;/p&gt;

&lt;p&gt;The minimum requirements to make it actually work as an &lt;strong&gt;installable PWA&lt;/strong&gt; are three:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;code&gt;manifest.json&lt;/code&gt; with a name, icons at least 192px and 512px, and &lt;code&gt;display: "standalone"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;service worker&lt;/strong&gt; for offline caching and installability&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTPS&lt;/strong&gt;, mandatory but already provided free by many hosting providers&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Progress saving, at this stage, is deliberately simple: &lt;code&gt;localStorage&lt;/code&gt;, no backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Breakout
&lt;/h2&gt;

&lt;p&gt;Among the options considered — a grid puzzle, a themed memory game, a timed quiz, a Wordle-like, a light idle-management game — Breakout won on the ratio between development time and immediate payoff: simple collision logic, native canvas with no complex sprites, and a genre that lends itself well to becoming an installable arcade app.&lt;/p&gt;

&lt;p&gt;The initial choices were deliberately minimal to get started quickly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Iteration&lt;/th&gt;
&lt;th&gt;What it adds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 — Skeleton&lt;/td&gt;
&lt;td&gt;Canvas, paddle, ball, random bricks, installable PWA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 — Levels&lt;/td&gt;
&lt;td&gt;10 then 15 fixed levels, multi-hit and indestructible bricks, level selector&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 — Visuals and audio&lt;/td&gt;
&lt;td&gt;Trail, particles, synthwave background, sounds via Web Audio API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 — Game feel&lt;/td&gt;
&lt;td&gt;Screen shake, 4 power-ups, drop-probability balancing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  From random levels to 15 hand-drawn ones
&lt;/h2&gt;

&lt;p&gt;The first version generated bricks with a semi-random grid: functional, but with no personality. The next step was replacing it with hand-drawn patterns — pyramids, checkerboards, diamonds, corridors, a "fortress" — each with a deliberate difficulty curve.&lt;/p&gt;

&lt;p&gt;At that point two kinds of special brick came in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;multi-hit&lt;/strong&gt;: need two hits, change color after the first and award more points&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;indestructible&lt;/strong&gt;: bounce the ball but never break and don't count toward level completion&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 An easy detail to forget: once you add indestructible bricks, every place in the code that checks "how many bricks are left to finish the level" needs to explicitly exclude them, or the level becomes impossible to complete.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;With 10 fixed levels came the rest of the progression too: high score and highest level reached saved together in &lt;code&gt;localStorage&lt;/code&gt;, and a level selector that unlocks only levels already seen at least once. Later the progression was extended to 15 levels, with denser patterns toward the end, up to a final level that's almost entirely indestructible except for one opening row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Neon visuals and sound without a single audio file
&lt;/h2&gt;

&lt;p&gt;The base version worked but looked visually bare. Next came a glowing trail behind the ball, colored particles when a brick explodes, a synthwave-style grid-and-scanline background, a pulsing glow on the paddle, and a flash on contact with the ball.&lt;/p&gt;

&lt;p&gt;For audio, the goal was zero files to download: the &lt;strong&gt;Web Audio API&lt;/strong&gt; lets you generate oscillators directly in JavaScript, modulating frequency and volume over time:&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;function&lt;/span&gt; &lt;span class="nf"&gt;beep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;freq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;square&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;volume&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.15&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;audioCtx&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;muted&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;osc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createOscillator&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;gain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createGain&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frequency&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="nx"&gt;freq&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&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="nx"&gt;volume&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exponentialRampToValueAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;audioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;osc&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="nx"&gt;gain&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="nx"&gt;audioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;duration&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;A different sound for a wall bounce, a paddle bounce, a normal brick, a multi-hit brick, and an indestructible one, plus short melodies for losing a life, game over, and level complete.&lt;/p&gt;

&lt;p&gt;One non-negotiable technical detail: browsers require a user interaction to start the audio context, so the first sound only plays on the first tap or click — it should be handled as expected behavior, not a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Screen shake and power-ups: from "it works" to "it feels alive"
&lt;/h2&gt;

&lt;p&gt;Screen shake, when you lose a life, is simpler than it sounds: apply a random, decreasing &lt;code&gt;translate()&lt;/code&gt; to the canvas context for a few frames before drawing the scene, without touching the real position of the ball, paddle, or bricks.&lt;/p&gt;

&lt;p&gt;For power-ups (wide paddle, slow ball, 3-ball multi-ball, extra life), the tricky part wasn't the idea but restructuring the update logic: the main ball and any extra multi-ball balls had to share the same behavior instead of duplicating code. The more robust fix was extracting a reusable &lt;code&gt;updateSingleBall()&lt;/code&gt; function, called in a loop over every ball on screen.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ The first attempt at "slow ball" scaled dx/dy every frame with a multiplicative factor, risking divide-by-zero when restoring speed. The more robust version applies the scaling once, on activation and deactivation of the effect, not every frame.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Balancing drop probabilities
&lt;/h2&gt;

&lt;p&gt;The first power-up version used a 28% total drop chance per normal brick destroyed — with hundreds of bricks per playthrough, the field got crowded far too fast.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Power-up&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🟢 Wide paddle&lt;/td&gt;
&lt;td&gt;9%&lt;/td&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🔵 Slow ball&lt;/td&gt;
&lt;td&gt;9%&lt;/td&gt;
&lt;td&gt;4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🟡 Multi-ball&lt;/td&gt;
&lt;td&gt;7%&lt;/td&gt;
&lt;td&gt;3.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🔴 Extra life&lt;/td&gt;
&lt;td&gt;3%&lt;/td&gt;
&lt;td&gt;1.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The general principle: minor helpers can stay relatively frequent because their impact is limited and temporary, while strong ones need to stay rare — otherwise the game loses the tension that makes it interesting. Power-ups only drop from normal bricks, never from multi-hit or indestructible ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it landed
&lt;/h2&gt;

&lt;p&gt;The game is now a fully installable PWA: 15 fixed levels with multi-hit and indestructible bricks, a level selector, visual and audio effects generated without external assets, screen shake, and 4 balanced power-ups. The service worker caches all assets for offline use, and the cache version gets bumped on every deploy to avoid mismatches between the served and cached versions.&lt;/p&gt;

&lt;p&gt;Openly postponed for now: more advanced power-ups and syncing scores across devices via a backend, instead of the current &lt;code&gt;localStorage&lt;/code&gt;-only approach.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Original article with the bilingual IT/EN version, FAQ, and a live demo of the game: &lt;a href="https://roversia.it/blog-17-pwa-gioco-breakout-canvas-audio-powerup.html" rel="noopener noreferrer"&gt;roversia.it&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>pwa</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Spark vs Blaze: The Firebase Pricing Guide I Wish I'd Read Sooner</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Mon, 06 Jul 2026 09:27:29 +0000</pubDate>
      <link>https://dev.to/androve2k/spark-vs-blaze-the-firebase-pricing-guide-i-wish-id-read-sooner-onb</link>
      <guid>https://dev.to/androve2k/spark-vs-blaze-the-firebase-pricing-guide-i-wish-id-read-sooner-onb</guid>
      <description>&lt;p&gt;"Firebase is free until it isn't" is the kind of sentence that scares more than it informs. Here I try to strip away the marketing jargon: what Spark actually includes, how Blaze's pay-as-you-go really works, what Firestore, Hosting, Storage and Authentication actually cost — and the February 2026 change that shifts the picture on Cloud Storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Firebase looks riskier than it is
&lt;/h2&gt;

&lt;p&gt;The most common fear is "pay-as-you-go with no ceiling" — and it's not unfounded: Firebase has no built-in spending cap, a bug that triggers an infinite read loop on Firestore really can produce a four-figure bill in a day. But that scenario has little to do with normal use on a personal project or a small product: it's a monitoring and guardrail problem, not an intrinsic property of Firebase's pricing.&lt;/p&gt;

&lt;p&gt;Some of the confusion also comes from "Spark" and "Blaze" being presented as two alternative plans, as if one were "free" and the other "paid". In practice they're more like two modes sharing the same free-quota base: Blaze adds the ability to go over them by paying, and unlocks products that aren't available on Spark at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spark plan: what it actually includes
&lt;/h2&gt;

&lt;p&gt;Spark is the default plan when creating a Firebase project. No credit card required, and it can never generate a bill: when a product exhausts its daily or monthly quota, it simply stops responding until the next reset. It's the right choice for prototypes, personal portfolios, and low-traffic tools.&lt;/p&gt;

&lt;p&gt;It includes, with no time limit: Authentication with standard providers (email/password, Google, Apple, GitHub, anonymous), Cloud Messaging, Analytics, Crashlytics, Performance Monitoring — all free regardless of volume. Firestore, Hosting, and Cloud Functions instead have precise quantitative quotas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Firestore: 1 GiB storage, 50,000 reads/day, 20,000 writes/day, 20,000 deletes/day, 10 GiB egress/month&lt;/li&gt;
&lt;li&gt;Hosting: 10 GB storage, 10 GB transfer/month&lt;/li&gt;
&lt;li&gt;Cloud Functions: 2 million invocations/month&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Cloud Storage is no longer included on Spark.&lt;/strong&gt; More on this below: since February 3, 2026, it always requires Blaze, even at minimal volumes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Blaze plan: pay-as-you-go, not "paid"
&lt;/h2&gt;

&lt;p&gt;Moving to Blaze doesn't mean you start paying immediately. It means linking a Google Cloud billing account and getting two things: the same free quotas from Spark stay valid (calculated daily for most products), and exceeding them no longer blocks the service but bills the overage at pay-as-you-go rates. On top of that, Blaze unlocks products unavailable on Spark: Cloud Storage, Cloud Functions beyond the base quota, outbound calls to external services from Cloud Functions, and access to broader Google Cloud products (Pub/Sub, Cloud Run, BigQuery streaming).&lt;/p&gt;

&lt;p&gt;If the project stays within the free quotas even on Blaze, the bill is zero — the difference is that a valid credit card must now be linked to the project, and Google often offers $300 in initial credit for those upgrading to Blaze for the first time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing table by service
&lt;/h2&gt;

&lt;p&gt;Official Google Cloud pricing for the nam5/us-central1 region, updated as of June 2026. Other regions may have slightly different rates.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Free tier (Spark &amp;amp; Blaze)&lt;/th&gt;
&lt;th&gt;Price above quota (Blaze)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Firestore — reads&lt;/td&gt;
&lt;td&gt;50,000/day&lt;/td&gt;
&lt;td&gt;$0.03 / 100,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firestore — writes&lt;/td&gt;
&lt;td&gt;20,000/day&lt;/td&gt;
&lt;td&gt;$0.09 / 100,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firestore — deletes&lt;/td&gt;
&lt;td&gt;20,000/day&lt;/td&gt;
&lt;td&gt;$0.01 / 100,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firestore — storage&lt;/td&gt;
&lt;td&gt;1 GiB&lt;/td&gt;
&lt;td&gt;≈ $0.15 / GB / month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting — storage&lt;/td&gt;
&lt;td&gt;10 GB&lt;/td&gt;
&lt;td&gt;$0.026 / GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting — transfer&lt;/td&gt;
&lt;td&gt;10 GB / month&lt;/td&gt;
&lt;td&gt;$0.15 / GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud Storage — storage&lt;/td&gt;
&lt;td&gt;5 GB-months (Blaze only)&lt;/td&gt;
&lt;td&gt;≈ $0.02 / GB / month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud Storage — download&lt;/td&gt;
&lt;td&gt;100 GB / month (Blaze only)&lt;/td&gt;
&lt;td&gt;$0.12 / GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standard Authentication&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;Always free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication — SMS&lt;/td&gt;
&lt;td&gt;10/day (test only)&lt;/td&gt;
&lt;td&gt;$0.01–0.06 / verification&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;→ &lt;a href="https://roversia.it/utility/firebase-cost-calculator.html" rel="noopener noreferrer"&gt;Use the interactive calculator&lt;/a&gt; to estimate your specific case.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 2026 change: Cloud Storage always requires Blaze
&lt;/h2&gt;

&lt;p&gt;Until February 2, 2026, Cloud Storage for Firebase was also available on Spark with its own dedicated free quota. Since February 3, 2026, Google has aligned Cloud Storage with standard Google Cloud Storage rules: creating or maintaining a bucket requires a linked billing account, meaning the Blaze plan, regardless of usage volume.&lt;/p&gt;

&lt;p&gt;This isn't a disguised price increase: if you stay within Google Cloud Storage's "Always Free" tier — 5 GB-months of storage and 100 GB of egress to North America per month — the bill stays at zero. What changes is the entry threshold: a project that previously used Storage while staying on Spark now must link a credit card, even just to save a few profile pictures.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to set up a budget alert on Google Cloud
&lt;/h2&gt;

&lt;p&gt;The simplest way to avoid being surprised by Blaze is to set up a budget with notification thresholds, directly from the billing console:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Google Cloud Console → Billing → Budgets &amp;amp; alerts&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Create a new budget, choose the scope (specific project or entire billing account)&lt;/li&gt;
&lt;li&gt;Set the monthly target amount — even just €5-10 for a personal project is a good warning signal&lt;/li&gt;
&lt;li&gt;Configure notification thresholds at 50%, 90%, and 100% of the amount&lt;/li&gt;
&lt;li&gt;Link a monitoring email address, not just the account owner's&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 The budget alert &lt;strong&gt;doesn't block spending&lt;/strong&gt;, it only sends a notification. For an actual cutoff you need a Cloud Function linked to Pub/Sub that disables billing when the threshold is exceeded — useful for hobby projects, not recommended for production products since it would shut off the service for real users.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I have to move to Blaze if my project is small?&lt;/strong&gt;&lt;br&gt;
No, if you only use Firestore, Hosting and standard Authentication under the free quotas, Spark is enough and stays at guaranteed zero cost, no card required. Blaze is only needed for Cloud Storage, Cloud Functions beyond the base quota, or if you exceed Firestore's daily quotas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does moving to Blaze mean I start paying immediately?&lt;/strong&gt;&lt;br&gt;
No. Blaze includes the same free quotas as Spark plus pay-as-you-go for the overage. If you stay within those quotas the bill is zero even on Blaze — the only difference is a linked card and no more automatic block at the limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does Cloud Storage always require Blaze since 2026?&lt;/strong&gt;&lt;br&gt;
Since February 3, 2026, Google aligned Cloud Storage for Firebase with standard Google Cloud Storage rules, which require a linked billing account to create a bucket, even while staying in the "Always Free" tier. It's a policy change, not a price increase: under 5 GB-months and 100 GB of transfer the bill stays zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I avoid a surprise bill on Blaze?&lt;/strong&gt;&lt;br&gt;
Set a budget with alert thresholds in the Google Cloud billing console — it notifies by email but doesn't block spending. Also monitor the Firestore and Storage dashboards periodically: an unclosed listener or a read loop can generate thousands of operations in a few hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which service most often causes unexpected costs?&lt;/strong&gt;&lt;br&gt;
Firestore, almost always for reads — a realtime listener left active on a growing collection multiplies reads much faster than the app's actual traffic would suggest. The second candidate is Hosting or Storage egress when large files are served without a CDN or compression.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://roversia.it/blog-15-spark-vs-blaze-prezzi-firebase.html" rel="noopener noreferrer"&gt;roversia.it&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>firebase</category>
      <category>googlecloud</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Integrating myPOS IPC into a Netlify Checkout: RSA Signatures, Error Code 25 and the Traps in the Docs</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Fri, 03 Jul 2026 09:22:53 +0000</pubDate>
      <link>https://dev.to/androve2k/integrating-mypos-ipc-into-a-netlify-checkout-rsa-signatures-error-code-25-and-the-traps-in-the-1dd5</link>
      <guid>https://dev.to/androve2k/integrating-mypos-ipc-into-a-netlify-checkout-rsa-signatures-error-code-25-and-the-traps-in-the-1dd5</guid>
      <description>&lt;p&gt;Replacing a plain payment link with a direct myPOS Integrated Payment Checkout (IPC) v1.4 integration looked like an afternoon of work. It turned into an obstacle course: a 1024-bit RSA key rejected by OpenSSL 3, a double-base64 signature algorithm documented ambiguously, and a mysterious Error Code 25 that turned out to be caused by the free hosting domain. Here is every step, error and fix, in order.&lt;/p&gt;

&lt;h2&gt;
  
  
  The context
&lt;/h2&gt;

&lt;p&gt;A client running a small custom e-commerce site on Firebase and Netlify Functions used a plain payment link, manually generated from the myPOS portal, for checkout. The obvious next step was integrating myPOS's IPC API directly, so the order total would be transmitted automatically to the gateway instead of being copy-pasted into a link. The client forwarded me the credentials issued by their payment provider over chat — not an ideal practice, but a common one when the technical contact is a third party relative to whoever manages the myPOS account.&lt;/p&gt;

&lt;p&gt;The initial plan was simple: a Netlify Function receives the total from the frontend, signs the request with the RSA private key, and forwards it to myPOS server-side, returning the payment URL to the browser. Step by step, I discovered that almost none of these assumptions held.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Security note:&lt;/strong&gt; any private key or certificate received over chat should be considered compromised. Keys belong only in environment variables on the service that uses them (Netlify, in this case), never committed to code, and — if they travelled unencrypted through a chat — rotated from the provider's portal as soon as possible.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Obstacle #1 — the 1024-bit RSA key and OpenSSL 3
&lt;/h2&gt;

&lt;p&gt;The first attempt used Node.js's native &lt;code&gt;crypto&lt;/code&gt; module to sign the request with the received PEM private key. The Netlify deploy immediately failed with an unhelpful error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Errore tecnico: error:1E08010C:DECODER routines::unsupported
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first suspicion was a format issue: the key was PKCS#1 (&lt;code&gt;-----BEGIN RSA PRIVATE KEY-----&lt;/code&gt;) and maybe Node expected PKCS#8. I tried forcing explicit parsing with &lt;code&gt;crypto.createPrivateKey({ key, format: 'pem', type: 'pkcs1' })&lt;/code&gt;, with no luck — same error. The real cause was different: the key was a &lt;strong&gt;1024-bit RSA key&lt;/strong&gt;, and Node.js 18+ relies on OpenSSL 3 as its crypto backend, which deprecated RSA keys under 2048 bits for security reasons. It silently rejects them, in both PKCS#1 and PKCS#8, with a message that sounds like a format problem but actually hides a key-length limit.&lt;/p&gt;

&lt;p&gt;With no quick way to have the client regenerate a 2048-bit key, the fix was replacing &lt;code&gt;crypto&lt;/code&gt; with &lt;a href="https://www.npmjs.com/package/node-forge" rel="noopener noreferrer"&gt;node-forge&lt;/a&gt;, a pure-JavaScript crypto implementation that doesn't go through OpenSSL and therefore doesn't inherit that restriction:&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;forge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node-forge&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Node.js crypto rejects RSA keys &amp;lt; 2048 bit on OpenSSL 3.&lt;/span&gt;
&lt;span class="c1"&gt;// node-forge doesn't go through OpenSSL: no length restriction.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;privateKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;forge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pki&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;privateKeyFromPem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;privateKeyPem&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;md&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;forge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;md&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sha256&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="nx"&gt;md&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;dataToSign&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signatureBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;privateKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;md&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;signature&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;signatureBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;binary&lt;/span&gt;&lt;span class="dl"&gt;'&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;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Obstacle #2 — the mutual TLS that wasn't needed
&lt;/h2&gt;

&lt;p&gt;With the signature fixed, the HTTPS call to myPOS failed with a different error but the same root cause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error:0480006C:PEM routines::no start line
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HTTPS request included &lt;code&gt;cert&lt;/code&gt; and &lt;code&gt;key&lt;/code&gt; in its options to establish mutual TLS with myPOS — a common pattern in other client-certificate integrations. But here the two authentication mechanisms are redundant: application-level authentication happens entirely through the RSA signature in the request body, verified server-side by the gateway. Connection-level mutual TLS was superfluous and, with a sub-2048-bit key, triggered the same OpenSSL rejection seen earlier, this time in the TLS handshake instead of the application signature. Removing &lt;code&gt;cert&lt;/code&gt; and &lt;code&gt;key&lt;/code&gt; from the HTTPS request options eliminated the error with no loss of security.&lt;/p&gt;

&lt;h2&gt;
  
  
  Obstacle #3 — server-to-server instead of a browser redirect
&lt;/h2&gt;

&lt;p&gt;At this point the function signed correctly and got a response from myPOS — but that response was a full HTML page, not a JSON payload with a payment URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INFO   myPOS IPC response keys: [ '&amp;lt;!DOCTYPE html&amp;gt;&amp;lt;html lang', 'subset' ]
INFO   IPCResult: undefined
ERROR  myPOS HTML error code: unknown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The underlying misunderstanding was architectural: IPC isn't a REST API that answers a server-to-server call with JSON, but a &lt;strong&gt;redirect flow&lt;/strong&gt; protocol. The end customer must physically land, with their own browser, on the checkout page hosted by myPOS — because that's where card data collection and any 3D Secure authentication happen, both operations that PCI DSS standards forbid routing through an intermediate server.&lt;/p&gt;

&lt;p&gt;The fix moved the responsibility around: the Netlify Function only generates the parameters and signature and returns them to the browser, which uses them to build and auto-submit an HTML form directly to the myPOS endpoint:&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;function&lt;/span&gt; &lt;span class="nf"&gt;redirectToMyPOS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&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;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&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&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&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="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&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://www.mypos.com/vmp/checkout/&lt;/span&gt;&lt;span class="dl"&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;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&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;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&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;key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;input&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="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nb"&gt;document&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;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// the customer's browser, not the server, lands on myPOS&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;General pattern:&lt;/strong&gt; whenever a payment gateway needs to collect card data or run 3D Secure authentication, expect a browser-side redirect flow, not a server-to-server REST endpoint — even if the docs show POST examples that seem to suggest otherwise.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Obstacle #4 — the double base64 signature algorithm
&lt;/h2&gt;

&lt;p&gt;With the redirect flow in place, myPOS finally showed its checkout page — but rejected the request with &lt;strong&gt;Error Code 3&lt;/strong&gt;, invalid signature. The official docs describe the algorithm in a line that's easy to misread:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Concatenate all parameter values (excluding the signature) with a dash &lt;code&gt;-&lt;/code&gt;, in the exact request order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Base64-encode the result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Sign the base64 string (not the raw data) with RSA-SHA256&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Base64-encode the resulting binary signature → this is the &lt;code&gt;Signature&lt;/code&gt; parameter value&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first attempt got both the separator wrong (using &lt;code&gt;|&lt;/code&gt; instead of &lt;code&gt;-&lt;/code&gt;) and skipped step 2 entirely, signing the concatenated string directly. Once the algorithm was fixed, the error changed but didn't disappear — a sign the problem had shifted to a different detail: the &lt;strong&gt;case-sensitivity&lt;/strong&gt; of parameter names. The payload used &lt;code&gt;IPCmethod&lt;/code&gt;, but myPOS expects exactly &lt;code&gt;IPCMethod&lt;/code&gt;: a required parameter with the wrong letter case is treated as missing, not as wrong, producing the more generic Error Code 1.&lt;/p&gt;

&lt;p&gt;The official PHP example hides another mirror-image trap: for the field representing the wallet number, the sample code uses all-lowercase &lt;code&gt;walletnumber&lt;/code&gt;, breaking with the PascalCase style of every other parameter — copying the "consistent" style of the other fields leads to the wrong name. Finally, the official PHP SDK always includes a cart block (&lt;code&gt;CartItems&lt;/code&gt;, &lt;code&gt;Article_1&lt;/code&gt;, &lt;code&gt;Quantity_1&lt;/code&gt;, &lt;code&gt;Price_1&lt;/code&gt;, &lt;code&gt;Currency_1&lt;/code&gt;, &lt;code&gt;Amount_1&lt;/code&gt;) and the &lt;code&gt;PaymentMethod&lt;/code&gt; parameter, both absent from the documentation's minimal examples but required in practice.&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;// Explicit order, not left to Object.values() on an object&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orderedValues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;IPCMethod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;IPCVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;IPCLanguage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;walletnumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OrderID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;URL_OK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;URL_Cancel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;URL_Notify&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CardTokenRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KeyIndex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PaymentMethod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CartItems&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Article_1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Quantity_1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Price_1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Currency_1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Amount_1&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;joined&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;orderedValues&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b64input&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;joined&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;base64&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;md&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;forge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;md&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sha256&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="nx"&gt;md&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;b64input&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="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Signature&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;privateKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;md&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;binary&lt;/span&gt;&lt;span class="dl"&gt;'&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;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Obstacle #5 — Error Code 25 and the .netlify.app domain
&lt;/h2&gt;

&lt;p&gt;With a finally valid signature, one last error remained: &lt;strong&gt;Error Code 25&lt;/strong&gt;, which per myPOS documentation means "store restricted" — the store isn't approved, or one of the request URLs isn't whitelisted.&lt;/p&gt;

&lt;p&gt;The myPOS portal configuration correctly listed all four required URLs (checkout page, notify, success, cancel). The problem wasn't a missing URL, but the domain itself: the site was hosted on a free &lt;code&gt;*.netlify.app&lt;/code&gt; subdomain, sharing its root domain with thousands of other Netlify projects. Payment gateway fraud systems often treat such domains with more suspicion than a dedicated domain, and this — rather than a specific misconfiguration — was likely the cause of the persistent Error Code 25.&lt;/p&gt;

&lt;p&gt;The fix was migrating the checkout to a custom subdomain, without touching the client's main domain (already serving a different site):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Added a CNAME record in the client's domain DNS, pointing to the existing Netlify site&lt;/li&gt;
&lt;li&gt;Configured the custom domain in Netlify → Domain management, keeping the original &lt;code&gt;.netlify.app&lt;/code&gt; subdomain active in parallel&lt;/li&gt;
&lt;li&gt;Updated all URLs in the myPOS portal (site, checkout, notify, outcome) to the new domain&lt;/li&gt;
&lt;li&gt;Waited for Let's Encrypt SSL certificate provisioning — myPOS silently rejects HTTPS URLs whose certificate isn't active yet, so testing too early gives the impression the problem persists when really only the certificate is missing&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Error Code&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Cause in this case&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Missing required parameters&lt;/td&gt;
&lt;td&gt;Wrong parameter case (&lt;code&gt;IPCmethod&lt;/code&gt; instead of &lt;code&gt;IPCMethod&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Invalid signature&lt;/td&gt;
&lt;td&gt;Wrong separator and missing base64 step in the string to sign&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;Restricted store / unapproved URLs&lt;/td&gt;
&lt;td&gt;Free &lt;code&gt;*.netlify.app&lt;/code&gt; domain, fixed with a custom subdomain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What I'm taking away
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A cryptic crypto-library error needs translating, not taking literally.&lt;/strong&gt; "DECODER routines::unsupported" sounds like a PEM format issue, but on Node 18+/OpenSSL 3 it's almost always a key-length limit (RSA &amp;lt; 2048 bit). node-forge is a workaround when the key itself can't be changed upstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not every security mechanism in an integration is needed at the same time.&lt;/strong&gt; An application-level signature in the payload and connection-level mutual TLS are often alternatives, not additive: using both can introduce errors instead of strengthening security.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An endpoint returning HTML instead of JSON is an architectural clue.&lt;/strong&gt; If a payment gateway responds with a page instead of a structured payload, it probably expects a browser-side redirect flow from the end customer, not a server-to-server call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Official API examples need to be read character by character.&lt;/strong&gt; Case-sensitivity in parameter names and style inconsistencies between fields (like lowercase &lt;code&gt;walletnumber&lt;/code&gt; among otherwise PascalCase parameters) are silent failures: the system doesn't report "wrong name," it reports "missing parameter."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A free hosting domain can be treated with suspicion by fraud systems.&lt;/strong&gt; If a payment gateway rejects an apparently well-configured store, it's worth testing a migration to a dedicated domain before continuing to chase configuration details.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://roversia.it/blog-13-integrazione-mypos-ipc-checkout.html" rel="noopener noreferrer"&gt;roversia.it&lt;/a&gt;, where I write about the vanilla JS/Firebase/Netlify stack behind my projects.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>netlify</category>
      <category>node</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Who's Online on the Site, Without Tidio: Live Presence and Visitor History with Firebase</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Fri, 03 Jul 2026 09:22:02 +0000</pubDate>
      <link>https://dev.to/androve2k/whos-online-on-the-site-without-tidio-live-presence-and-visitor-history-with-firebase-37il</link>
      <guid>https://dev.to/androve2k/whos-online-on-the-site-without-tidio-live-presence-and-visitor-history-with-firebase-37il</guid>
      <description>&lt;p&gt;A client wanted to know who was on their site and on which page, the way Tidio's widget showed them — but without paying for a Tidio subscription, on an external WordPress site that doesn't use Firebase. The result: an external tracker hooked to an independent Firebase project, live presence via &lt;code&gt;onDisconnect&lt;/code&gt;, persistent history in Firestore with IP geolocation — and a final debugging session where a browser CORS error was masking a server crash caused by an empty string instead of &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The context
&lt;/h2&gt;

&lt;p&gt;The client already had a third-party live-chat script installed on their site, and that's where the idea came from: "can we see who's on the site and on which page, without using that service?" Two constraints made the request less trivial than usual: the site hadn't been migrated to my usual stack yet — it was still running on WordPress, on different hosting — and there was no intention of introducing Firebase on the WordPress side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — understanding what a live-chat widget actually does
&lt;/h2&gt;

&lt;p&gt;Before building anything, it was worth looking at what the already-installed script actually did. The tag pasted into the site was just a small loader: it creates a hidden iframe, loads the widget's real "brain" inside it from the provider's servers, which then connects via websocket to their backend to stream presence, current page and events in real time.&lt;/p&gt;

&lt;p&gt;The interesting part — "see who's on the site and on which page" — isn't in the public script: it all lives server-side at the provider, behind authentication, a proprietary dashboard and a subscription. There was nothing to "detach" from that service: it's client code tied to someone else's backend by design. But the pattern itself — a script tag hooking into an external backend — is exactly what's needed to build the same feature independently, and it fits well with Firebase, which has a native presence mechanism built for precisely this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — live presence with Firebase Realtime Database
&lt;/h2&gt;

&lt;p&gt;Firebase Realtime Database has a native presence pattern based on &lt;code&gt;onDisconnect()&lt;/code&gt;: it registers a write or delete operation server-side to run automatically whenever the client's connection drops, no matter how — tab closed, network lost, browser crash — without the client having to do anything at that moment.&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;// Every visitor gets an anonymous session id (localStorage)&lt;/span&gt;
&lt;span class="c1"&gt;// and writes under sitePresence/{sessionId}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="o"&gt;=&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;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`sitePresence/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sessionId&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="c1"&gt;// What to write when the connection drops, registered IMMEDIATELY&lt;/span&gt;
&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onDisconnect&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Only after that, the "online" data with current page&lt;/span&gt;
&lt;span class="nx"&gt;ref&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="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;referrer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referrer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;lastSeen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;firebase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ServerValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TIMESTAMP&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail worth noting: &lt;code&gt;onDisconnect()&lt;/code&gt; must be registered &lt;strong&gt;before&lt;/strong&gt; writing the "online" data, not after — otherwise there's a window where the client shows as online in the database but the server hasn't yet received instructions on what to do if it disconnects abruptly during that same window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — a tag on a site that isn't yours
&lt;/h2&gt;

&lt;p&gt;The site stayed on WordPress, on different hosting, without touching the CMS's PHP or database. Just like the original live-chat widget, a single script tag pasted into the theme's header or footer (or via a plugin like "Insert Headers and Footers") loads a JavaScript file hosted elsewhere, which connects to a Firebase project completely external to the WordPress site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://[client-panel].example/site-tracker.js"&lt;/span&gt; &lt;span class="na"&gt;async&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tracker generates an anonymous session id (localStorage), signs in anonymously via Firebase Authentication, and writes presence, current page, referrer/UTM and user agent — with &lt;code&gt;onDisconnect()&lt;/code&gt; for automatic cleanup. The WordPress site's domain doesn't need to be added anywhere on the Firebase side: Firebase Authentication's "Authorized domains" list only affects OAuth redirect/popup flows, while &lt;code&gt;signInAnonymously()&lt;/code&gt; works from any origin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — the rules: who reads, who writes
&lt;/h2&gt;

&lt;p&gt;With anonymous authentication already in use elsewhere in the Firebase project, what remained was correctly isolating the new &lt;code&gt;sitePresence&lt;/code&gt; node: anonymous visitors must be able to write only their own session node, never read anyone else's; only the admin account should be able to read the full list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&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;"rules"&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="nl"&gt;"sitePresence"&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="nl"&gt;".read"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth != null &amp;amp;&amp;amp; auth.uid === '&amp;lt;ADMIN_UID&amp;gt;'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"$sid"&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="nl"&gt;".write"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth != null"&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;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;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Granularity note:&lt;/strong&gt; with this rule, an anonymous session could in theory overwrite another anonymous session's node, since the id is client-generated and not checked against &lt;code&gt;auth.uid&lt;/code&gt;. For presence/analytics-only data — no sensitive data involved — that's an acceptable trade-off; to lock it down fully, save the &lt;code&gt;auth.uid&lt;/code&gt; in the record and check it in the write rule.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 5 — persistent history and geolocation
&lt;/h2&gt;

&lt;p&gt;Live presence answers "who's here now", but it isn't enough for statistics: data under &lt;code&gt;sitePresence&lt;/code&gt; disappears on disconnect, by design. A durable history needs a different place — Firestore, with a dedicated collection written by a Netlify Function instead of the client, so the IP can also be geolocated server-side without exposing any key in the browser.&lt;/p&gt;

&lt;p&gt;An honest note on geolocation: no free IP-geolocation service returns the exact Italian province — that's too fine-grained a detail for a technique that's mostly accurate at city/region level, sometimes only at ISP level. City and region are the best available without a dedicated paid service; province-level accuracy for major cities would need an extra lookup table.&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;// Written ONLY by the Netlify Function via service account,&lt;/span&gt;
&lt;span class="c1"&gt;// which bypasses these rules anyway: write:false is defense in depth.&lt;/span&gt;
&lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;siteVisits&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&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;auth&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="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kc"&gt;false&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;h2&gt;
  
  
  Obstacle #1 — the immutable cache hiding every update
&lt;/h2&gt;

&lt;p&gt;After the first deploy, live presence worked — the client could see themselves navigating in real time — but Firestore history stayed at zero sessions. The main suspect: &lt;code&gt;.js&lt;/code&gt; files served by Netlify carried &lt;code&gt;Cache-Control: public, max-age=31536000, immutable&lt;/code&gt; by project convention, meant for internal assets versioned with &lt;code&gt;?v=&lt;/code&gt;. But the tag on the WordPress site pointed to a version-less URL — so on every tracker update, visitors kept downloading the old version for a full year, with no practical way to force a refresh short of asking the client to manually edit the tag every time.&lt;/p&gt;

&lt;p&gt;The fix wasn't manually bumping a &lt;code&gt;?v=&lt;/code&gt; on every deploy — that would have meant a WordPress edit for every single change — but giving that specific file a much shorter cache policy, so it updates itself:&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="nn"&gt;[[headers]]&lt;/span&gt;
  &lt;span class="py"&gt;for&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/site-tracker.js"&lt;/span&gt;
  &lt;span class="nn"&gt;[headers.values]&lt;/span&gt;
    &lt;span class="py"&gt;Cache-Control&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"public, max-age=300, must-revalidate"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The general &lt;code&gt;/*.js&lt;/code&gt; rule (one year, immutable) stays intact for all internally-versioned assets; only the externally-exposed tracker gets a five-minute cache. From that point on, every file update reaches visitors within minutes, with no need to ever touch the WordPress tag again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Obstacle #2 — the CORS error that was actually a crash on the 204 response
&lt;/h2&gt;

&lt;p&gt;With caching fixed, live presence kept working but history stayed empty. The browser showed an unambiguous error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Access to fetch at '.../site-visit-log' from origin '...' has been blocked
by CORS policy: Response to preflight request doesn't pass access control
check: No 'Access-Control-Allow-Origin' header is present on the requested
resource.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A direct test of the function via &lt;code&gt;curl&lt;/code&gt; with a POST request, bypassing the browser, consistently returned &lt;code&gt;200 OK&lt;/code&gt; — which seemed to confirm a CORS domain-whitelist issue. But that test wasn't actually replicating what a browser does: before a cross-origin POST with &lt;code&gt;Content-Type: application/json&lt;/code&gt;, the browser always issues a &lt;strong&gt;preflight OPTIONS&lt;/strong&gt; request, which &lt;code&gt;curl -X POST&lt;/code&gt; skips entirely. The "passing" test wasn't exercising the path that was actually broken.&lt;/p&gt;

&lt;p&gt;Repeating the test with a real OPTIONS preflight, and checking the function's invocation logs on Netlify, revealed the real cause — an exception thrown before a response could even be built:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: Response constructor: Invalid response status code 204
    at initializeResponse (node:internal/deps/undici/undici)
    at new Response (node:internal/deps/undici/undici)
    at Object.handler (site-visit-log.mjs:88)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cause: the OPTIONS response was built with status &lt;code&gt;204&lt;/code&gt; and a body set to an &lt;strong&gt;empty string&lt;/strong&gt; (&lt;code&gt;''&lt;/code&gt;) instead of &lt;code&gt;null&lt;/code&gt;. The Fetch standard treats "no content" statuses — 204, 205, 304 — as incompatible with any body, even an empty one; Node.js, via the &lt;code&gt;undici&lt;/code&gt; library that implements &lt;code&gt;Response&lt;/code&gt;, enforces this strictly and throws in the constructor itself, before the response is even sent.&lt;/p&gt;

&lt;p&gt;Hence the false lead: the function was crashing internally on &lt;em&gt;every&lt;/em&gt; OPTIONS request, so Netlify returned a generic error with no headers. The browser, finding no &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; in the response, reported — correctly from its own point of view, but misleadingly about the cause — a CORS block. A server crash disguised as a domain-configuration problem.&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;// Before — crashes: empty body not allowed on status 204&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;204&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="nx"&gt;corsHeaders&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// After — explicitly null body&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&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="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="mi"&gt;204&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="nx"&gt;corsHeaders&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Operational lesson:&lt;/strong&gt; a &lt;code&gt;curl -X POST&lt;/code&gt; test doesn't prove a browser cross-origin call works. To really verify a browser-facing endpoint, the preflight must be replicated explicitly: &lt;code&gt;curl -X OPTIONS ... -H "Origin: ..." -H "Access-Control-Request-Method: POST"&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Obstacle #3 — missing data because it was only written on the first event
&lt;/h2&gt;

&lt;p&gt;With history finally populated, two more symptoms of the same underlying bug showed up at different times: the "Today / 7 days / 30 days" filters in the admin panel returned zero results even with real sessions already logged, and some sessions showed "Unknown" city/region despite geolocation working for others.&lt;/p&gt;

&lt;p&gt;In both cases the cause was identical: a field — the first-seen timestamp and the geolocation data, respectively — was computed and written only on the &lt;code&gt;start&lt;/code&gt; event (a session's first page load), never on subsequent &lt;code&gt;update&lt;/code&gt; events. Sessions whose first event reaching the server was already an &lt;code&gt;update&lt;/code&gt; — typical of visitors with a still-valid session in localStorage from before a deploy — stayed permanently without that field: date filters use it to include or exclude sessions, so they excluded all of them, while geolocation stayed empty forever.&lt;/p&gt;

&lt;p&gt;The fix was the same in both cases: recompute and rewrite the field on &lt;strong&gt;every&lt;/strong&gt; event, not just on &lt;code&gt;start&lt;/code&gt;. An idempotent operation — the exact same value for the whole session lifetime — so there's no risk of overwriting a correct value, with the added benefit that already-incomplete sessions self-correct on their next event, with no manual intervention needed on old data.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;History stuck at zero sessions&lt;/td&gt;
&lt;td&gt;One-year immutable cache on the tracker: visitors were still downloading the old script version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"No Access-Control-Allow-Origin" in the browser&lt;/td&gt;
&lt;td&gt;Crash in the &lt;code&gt;Response&lt;/code&gt; constructor: empty-string body on status 204 instead of &lt;code&gt;null&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Today/7d/30d filters empty, "Unknown" geo&lt;/td&gt;
&lt;td&gt;Fields computed only on the &lt;code&gt;start&lt;/code&gt; event, never on &lt;code&gt;update&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What I'm taking away
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Analyzing a third-party widget before replacing it saves time.&lt;/strong&gt; Realizing the public tag is just a loader tied to a proprietary backend immediately clarified that the goal was to rebuild an equivalent backend, not "detach" functionality from a script that doesn't contain any.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A browser CORS error doesn't always mean a domain-whitelist problem.&lt;/strong&gt; If the server crashes before building a response, the browser sees no CORS headers and reports a block — indistinguishable, client-side, from a misconfiguration. Server-side invocation logs are the source of truth, not the browser console.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;curl -X POST&lt;/code&gt; test doesn't verify a browser-facing endpoint.&lt;/strong&gt; Cross-origin requests with JSON go through an OPTIONS preflight first, which a plain curl POST skips entirely; a "passing" test can hide the real breaking point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"No content" HTTP statuses require an explicitly &lt;code&gt;null&lt;/code&gt; body.&lt;/strong&gt; An empty string isn't equivalent to no body for 204/205/304: Node.js/undici enforces this strictly and throws in the response constructor itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Computing a value only on the "first" event assumes the server always sees that first event.&lt;/strong&gt; Sessions already existing on a client can reach the server with an intermediate event as their actual first contact; recomputing derived fields on every event, idempotently, avoids permanent data gaps.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://roversia.it/blog-14-tracciamento-visitatori-firebase.html" rel="noopener noreferrer"&gt;roversia.it&lt;/a&gt;, where I write about the vanilla JS/Firebase/Netlify stack behind my projects.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>firebase</category>
      <category>netlify</category>
      <category>javascript</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>Firebase PWA Security Audit: XSS via innerHTML, Hardcoded Credentials and a Custom Token Migration</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Thu, 02 Jul 2026 09:02:13 +0000</pubDate>
      <link>https://dev.to/androve2k/firebase-pwa-security-audit-xss-via-innerhtml-hardcoded-credentials-and-a-custom-token-migration-58nc</link>
      <guid>https://dev.to/androve2k/firebase-pwa-security-audit-xss-via-innerhtml-hardcoded-credentials-and-a-custom-token-migration-58nc</guid>
      <description>&lt;p&gt;Three vulnerabilities found in priority order in a vanilla JS Firebase management panel: XSS via direct interpolation of user data into &lt;code&gt;innerHTML&lt;/code&gt;, Firebase email and password in plain text in the client source, and an HMAC key hardcoded and shared between two PWAs. The fix required two JavaScript helpers, a Node.js Cloud Function that issues custom tokens, and migrating the secret to Secret Manager — with complications from CORS, IAM permissions, and a Cloud Function URL that changes between deploys.&lt;/p&gt;

&lt;h2&gt;
  
  
  The context
&lt;/h2&gt;

&lt;p&gt;The panel is an internal customer and order management tool, built entirely in vanilla JS with Firebase Realtime Database as the backend. It's used in real time by multiple operators simultaneously — every record change propagates via &lt;code&gt;onValue()&lt;/code&gt; to all open sessions. Personal data (name, email, phone, notes) is entered by operators but also, in part, from onboarding forms filled in by customers themselves.&lt;/p&gt;

&lt;p&gt;The audit started as a systematic review of the main file, roughly 3,500 lines. What emerged wasn't a single isolated problem but three distinct attack surfaces, each with a different risk level and fix complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vulnerability #1 — XSS via unsanitised innerHTML
&lt;/h2&gt;

&lt;p&gt;The entire customer table is built with template literals and assigned directly to the container's &lt;code&gt;innerHTML&lt;/code&gt;. Personal data is interpolated as-is:&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;// ❌ User data interpolated directly&lt;/span&gt;
&lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;td class="col-nome" data-tip="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nome&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" onclick="openViewModal('&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;')"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nome&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/td&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;td class="col-email"&amp;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="s2"&gt;&amp;lt;/td&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;td class="col-note"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nota&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/td&amp;gt;`&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 customer enters a name like &lt;code&gt;"&amp;gt;&amp;lt;img src=x onerror=alert(1)&amp;gt;&lt;/code&gt; in the onboarding form, that string lands unchanged in the DOM of every connected operator. With data coming from external forms, this isn't theoretical — a real payload could read session tokens, inject arbitrary code, or exfiltrate data, exploiting Firebase's real-time propagation to every open session for free.&lt;/p&gt;

&lt;p&gt;The fix: two helpers near Firebase initialization, applied systematically to every interpolation point.&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;// Escape for visible HTML content (cells, modal bodies, notes)&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;escapeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&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="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;/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;&amp;amp;amp;&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;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;/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;&amp;amp;lt;&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;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;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;&amp;amp;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;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;&amp;amp;quot;&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;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;&amp;amp;#039;&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="c1"&gt;// Additional escape for values inside onclick='...' and data-tip=""&lt;/span&gt;
&lt;span class="c1"&gt;// A single quote would break the JS string even after standard escapeHtml&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;escapeJsAttr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&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="nf"&gt;escapeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&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="se"&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="c1"&gt;// ✅ After the fix&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eNome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;escapeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nome&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;eId&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;escapeJsAttr&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;rows&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;td class="col-nome" data-tip="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;eNome&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" onclick="openViewModal('&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;eId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;')"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;eNome&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/td&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two helpers exist because the contexts differ: &lt;code&gt;escapeHtml&lt;/code&gt; protects visible content, while &lt;code&gt;escapeJsAttr&lt;/code&gt; handles the &lt;code&gt;onclick&lt;/code&gt; attribute case, where a single quote in a customer name would break the JS string even after normal HTML escaping. Applied across the main table, mobile cards, detail modal, notes list, and trash table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vulnerability #2 — Firebase credentials in plain text in the client source
&lt;/h2&gt;

&lt;p&gt;The Firebase initialization block of the main file contained this:&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;// ❌ Email and password in plaintext — visible via "View Source"&lt;/span&gt;
&lt;span class="nf"&gt;signInWithEmailAndPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[admin@example.com]&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;[PASSWORD]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anyone opening "View source" gets the shared Firebase account credentials. If the Realtime Database rules are restricted to that &lt;code&gt;auth.uid&lt;/code&gt;, those credentials are the full read/write key to the entire database — completely bypassing the app's own authentication layer.&lt;/p&gt;

&lt;p&gt;The same block held a second, related issue: an HMAC key used to verify tokens issued by the companion app, also hardcoded in plain text:&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;// ❌ Anyone reading the source can forge valid HMAC tokens&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[HMAC_SECRET]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// The same string was also present client-side in the companion app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exposed HMAC key is arguably worse than the password: anyone who knows it can forge a valid token from scratch, bypassing authentication entirely without ever touching an operator account.&lt;/p&gt;

&lt;h2&gt;
  
  
  The solution: Cloud Function mintAuthToken + Secret Manager
&lt;/h2&gt;

&lt;p&gt;The fix moves all sensitive logic server-side. The client no longer needs to know the Firebase password or the HMAC key for anything beyond a preliminary UX check. The new flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The companion app generates an HMAC-signed token with the secret and passes it in the portal URL as &lt;code&gt;?auth=...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The portal calls a Cloud Function, &lt;code&gt;mintAuthToken&lt;/code&gt;, passing the token&lt;/li&gt;
&lt;li&gt;The Cloud Function retrieves the HMAC secret from &lt;strong&gt;Secret Manager&lt;/strong&gt; (never in code), re-verifies the token, and if valid calls &lt;code&gt;createCustomToken(uid)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The portal receives the custom token and calls &lt;code&gt;signInWithCustomToken(auth, customToken)&lt;/code&gt; — no password in the client, ever
&lt;/li&gt;
&lt;/ol&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;onRequest&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;firebase-functions/v2/https&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&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;defineSecret&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;firebase-functions/params&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;admin&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;firebase-admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&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;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initializeApp&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;hmacSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineSecret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PANELCONTROL_HMAC_SECRET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Fixed UID of the service Firebase account — not a secret&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FIXED_UID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[UID_SERVICE_ACCOUNT]&lt;/span&gt;&lt;span class="dl"&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;mintAuthToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;onRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hmacSecret&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;europe-west1&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="c1"&gt;// Manual CORS — Firebase v2 doesn't support the *.netlify.app wildcard&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;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Access-Control-Allow-Origin&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;*&lt;/span&gt;&lt;span class="dl"&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;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Access-Control-Allow-Methods&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;POST, OPTIONS&lt;/span&gt;&lt;span class="dl"&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;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Access-Control-Allow-Headers&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;Content-Type&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OPTIONS&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;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;204&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="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="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;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timestamp&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="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="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;token&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;||&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="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;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;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;missing fields&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="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Expired token (5-minute window)&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;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&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="o"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="nx"&gt;_000&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;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;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token expired&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="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Verify HMAC using the secret from Secret Manager&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hmacSecret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;value&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;expected&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="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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;timestamp&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;nonce&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="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&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;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;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;token&lt;/span&gt;&lt;span class="p"&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;expected&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;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;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&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="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="c1"&gt;// Issue the Firebase custom token — no password in the client&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;customToken&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;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;createCustomToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FIXED_UID&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;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;customToken&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;The HMAC secret is set once with &lt;code&gt;firebase functions:secrets:set PANELCONTROL_HMAC_SECRET&lt;/code&gt; and never appears in code — not in the Cloud Function, not in the client. The function accesses it at runtime through Firebase's native Secret Manager integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Firebase CLI setup on Windows (from scratch)
&lt;/h2&gt;

&lt;p&gt;Without Node.js installed, the first obstacle on Windows is PowerShell's execution policy blocking third-party &lt;code&gt;.ps1&lt;/code&gt; files — including &lt;code&gt;npm.ps1&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Unblock script execution (one-time)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Set-ExecutionPolicy&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ExecutionPolicy&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;RemoteSigned&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Scope&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;CurrentUser&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# 2. Install Firebase CLI (after installing Node.js LTS from nodejs.org)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;npm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;install&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-g&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;firebase-tools&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# 3. Login and init (answers: JavaScript, ESLint → N, deps → Y)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;firebase&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;login&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;firebase&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;functions&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# 4. Generate a secure HMAC key (don't reuse the old one)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-e&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"console.log(require('crypto').randomBytes(32).toString('base64'))"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# 5. Set the secret (value requested interactively — never in shell history)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;firebase&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;functions:secrets:set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;PANELCONTROL_HMAC_SECRET&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# 6. Deploy&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;firebase&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;deploy&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--only&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;functions:mintAuthToken&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The CORS wall with Cloud Functions v2
&lt;/h2&gt;

&lt;p&gt;After the first deploy, the portal returned &lt;code&gt;Failed to fetch&lt;/code&gt; — the function was reachable but rejecting the request. Firebase Cloud Functions v2 doesn't support the &lt;code&gt;*.netlify.app&lt;/code&gt; wildcard in the &lt;code&gt;cors&lt;/code&gt; option of the &lt;code&gt;onRequest&lt;/code&gt; decorator. The fix: handle CORS manually.&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;// ❌ Doesn't work with dynamic Netlify subdomains (*.netlify.app)&lt;/span&gt;
&lt;span class="nf"&gt;onRequest&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;cors&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;secrets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hmacSecret&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ Manual handling — works for any origin&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="nx"&gt;res&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Access-Control-Allow-Origin&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;*&lt;/span&gt;&lt;span class="dl"&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;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Access-Control-Allow-Methods&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;POST, OPTIONS&lt;/span&gt;&lt;span class="dl"&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;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Access-Control-Allow-Headers&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;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Response to the OPTIONS preflight is mandatory&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OPTIONS&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;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;204&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="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="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... rest of the handler&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Missing IAM permission: Service Account Token Creator
&lt;/h2&gt;

&lt;p&gt;With CORS fixed, the function was reachable but returned HTTP 500. The reason: the Cloud Functions execution service account (&lt;code&gt;[PROJECT_NUMBER]-compute@developer.gserviceaccount.com&lt;/code&gt;) didn't have the &lt;strong&gt;Service Account Token Creator&lt;/strong&gt; role in Google Cloud IAM. That role is required for the Admin SDK's &lt;code&gt;createCustomToken()&lt;/code&gt;, which internally signs a JWT using the service account's private key.&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="c1"&gt;# Service account: [PROJECT_NUMBER]-compute@developer.gserviceaccount.com&lt;/span&gt;
&lt;span class="c1"&gt;# Roles already present by default:&lt;/span&gt;
&lt;span class="c1"&gt;#   roles/firebase.sdkAdminServiceAgent&lt;/span&gt;
&lt;span class="c1"&gt;#   roles/secretmanager.secretAccessor  (added automatically by deploy)&lt;/span&gt;

&lt;span class="c1"&gt;# Role to add manually:&lt;/span&gt;
&lt;span class="c1"&gt;#   roles/iam.serviceAccountTokenCreator&lt;/span&gt;
&lt;span class="c1"&gt;#   → required for admin.auth().createCustomToken(uid)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix: Google Cloud Console → IAM &amp;amp; Admin → IAM → find the compute service account → edit → add &lt;strong&gt;Service Account Token Creator&lt;/strong&gt; → save. No redeploy needed — the permission takes effect immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  HMAC secret rotation
&lt;/h2&gt;

&lt;p&gt;The secret hardcoded in the original source has to be treated as compromised — anyone who ever viewed the page source knows it. Moving it to Secret Manager with the &lt;em&gt;same&lt;/em&gt; value doesn't fix anything; it needs to be regenerated.&lt;/p&gt;

&lt;p&gt;Rotation touches three points that must be updated with the same new value, atomically (otherwise logins break mid-transition):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Secret Manager&lt;/strong&gt; — &lt;code&gt;firebase functions:secrets:set PANELCONTROL_HMAC_SECRET&lt;/code&gt;, answer Y to the redeploy prompt&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The management panel&lt;/strong&gt; — replace the old value in the client-side &lt;code&gt;SECRET&lt;/code&gt; variable (used only for the lock screen's immediate UX feedback, not real verification)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The companion app&lt;/strong&gt; — replace the value in its own secret variable and redeploy&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What remains — residual architecture and technical debt
&lt;/h2&gt;

&lt;p&gt;Two structural issues stayed open at lower priority:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Full table rebuild on every sync.&lt;/strong&gt; &lt;code&gt;renderTable&lt;/code&gt; rebuilds the entire &lt;code&gt;innerHTML&lt;/code&gt; on every &lt;code&gt;onValue('clienti')&lt;/code&gt; — i.e. on every single-record edit by any operator. Debouncing with &lt;code&gt;requestAnimationFrame&lt;/code&gt; groups close-together changes but doesn't remove the rebuild cost itself. The next real performance step would be per-row diffing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Residual &lt;code&gt;transition: all&lt;/code&gt;.&lt;/strong&gt; About ten isolated elements still use it instead of specific properties — minimal impact, low priority.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;innerHTML + user data = XSS, always.&lt;/strong&gt; In a realtime app fed by external forms, there's no way to know in advance what will arrive. Two centralized escape functions applied systematically beat any amount of upstream form validation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials in client source are permanently exposed.&lt;/strong&gt; No obfuscation is effective in the browser — source is always readable. Keep passwords and HMAC secrets server-side from day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;signInWithCustomToken&lt;/code&gt; is the right pattern for hybrid auth.&lt;/strong&gt; When a separate system already handles primary authentication, Firebase should only be a secondary provider fronted by a secure Cloud Function bridge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CORS in Cloud Functions v2 needs manual handling&lt;/strong&gt; when dynamic subdomains are involved — &lt;code&gt;cors: true&lt;/code&gt; doesn't cover it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service Account Token Creator is a non-obvious permission.&lt;/strong&gt; Not granted by default, and the error it produces (a generic HTTP 500) doesn't hint at IAM at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An already-exposed secret must be rotated, not just relocated.&lt;/strong&gt; Moving the old value to Secret Manager unchanged solves nothing — rotation is part of the fix, not an afterthought.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Full write-up on my blog: &lt;a href="https://roversia.it/blog-12-security-audit-xss-firebase-custom-token.html" rel="noopener noreferrer"&gt;roversia.it/blog-12-security-audit-xss-firebase-custom-token.html&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>firebase</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Debugging a Firebase Admin Panel — Wrong DB Node, Bad Key Mapping and a Date.now() ID Bug</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Thu, 02 Jul 2026 09:01:18 +0000</pubDate>
      <link>https://dev.to/androve2k/debugging-a-firebase-admin-panel-wrong-db-node-bad-key-mapping-and-a-datenow-id-bug-5dik</link>
      <guid>https://dev.to/androve2k/debugging-a-firebase-admin-panel-wrong-db-node-bad-key-mapping-and-a-datenow-id-bug-5dik</guid>
      <description>&lt;p&gt;The admin panel I use to manage a sales team collects leads from multiple sources. One of them is a Facebook campaign for distributing POS terminals, landing in a separate Firebase Realtime Database that until now was only visible through the Firebase Console web UI. The goal: surface those leads directly in the Admin, in a dedicated section for authorized users, sorted by submission date.&lt;/p&gt;

&lt;p&gt;Adding a new tab to the existing sidebar dispatcher is a surgical operation — three touch points (tabs array, labels map, &lt;code&gt;else if&lt;/code&gt; dispatcher) plus the actual render function. What should've been routine turned into three separate, unrelated bugs, each hiding the next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem one: wrong DB node
&lt;/h2&gt;

&lt;p&gt;The first version of the load function pointed at the database root:&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;// ❌ Points to root — returns null&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://[MY-PROJECT]-default-rtdb.firebasedatabase.app/.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ Data lives under the /leads node&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://[MY-PROJECT]-default-rtdb.firebasedatabase.app/leads.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database had read rules open only on the &lt;code&gt;leads&lt;/code&gt; node, not at the root. A fetch to &lt;code&gt;/.json&lt;/code&gt; returned &lt;code&gt;null&lt;/code&gt; because the root itself isn't accessible under proper rules. The fix was a three-character change to the URL.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Firebase RTDB rule:&lt;/strong&gt; read rules are applied node by node. Having &lt;code&gt;".read": true&lt;/code&gt; on &lt;code&gt;/leads&lt;/code&gt; does not grant access to the root. Always test the direct URL in the browser before writing any code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Problem two: wrong mapping keys
&lt;/h2&gt;

&lt;p&gt;Past the node problem, the table loaded but every column showed dashes (&lt;code&gt;—&lt;/code&gt;). Records existed, but no field read correctly — because I'd guessed key names instead of verifying them.&lt;/p&gt;

&lt;p&gt;The original mapping used "intuitive" names like &lt;code&gt;tipo_pos&lt;/code&gt;, &lt;code&gt;quantita&lt;/code&gt;, &lt;code&gt;cliente&lt;/code&gt; — while the DB actually contained:&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;// Actual keys in the Firebase DB&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cognome&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="s2"&gt;Roversi&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="s2"&gt;nome&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="s2"&gt;Andrea&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="s2"&gt;email&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="s2"&gt;email@example.com&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="s2"&gt;piano&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="s2"&gt;Smart&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="s2"&gt;pos_quantita&lt;/span&gt;&lt;span class="dl"&gt;"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pos_tipo&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="s2"&gt;POS con Stampante&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="s2"&gt;source&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="s2"&gt;facebook-lead&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="s2"&gt;timestamp&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="s2"&gt;2026-06-09T11:29:19.980Z&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="s2"&gt;tipo_cliente&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="s2"&gt;Privati &amp;amp; Startup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the keys were corrected, everything read fine. The ISO 8601 timestamp gets formatted with &lt;code&gt;toLocaleDateString('it-IT')&lt;/code&gt; before display.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Quick debug technique:&lt;/strong&gt; if you can't fetch directly from your dev environment, just open the &lt;code&gt;.json&lt;/code&gt; URL in the browser. Firebase RTDB returns readable JSON with exact key names, no tooling required.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Problem three: the dynamic ID that cut records in half
&lt;/h2&gt;

&lt;p&gt;With correct keys the table loaded — but showed only 2 records out of 4 in the DB. No console errors, no filters applied, all the data was there. This was the tricky one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root cause: setTimeout + Date.now()-based ID
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;renderSection()&lt;/code&gt; was generating a unique container ID on every call:&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;function&lt;/span&gt; &lt;span class="nf"&gt;renderSection&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;containerId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;section_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&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="c1"&gt;// ← unique ID on every render&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;div id="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;containerId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;Loading...&amp;lt;/div&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;mainArea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Timeout fires AFTER the DOM is updated&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&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;_loadSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;containerId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;50&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;When the user clicked the tab, &lt;code&gt;renderSection()&lt;/code&gt; fired twice in quick succession — once from the click, once from the internal routing system syncing state. The second call overwrote the DOM with a &lt;em&gt;new&lt;/em&gt; container ID before the first call's &lt;code&gt;setTimeout&lt;/code&gt; fired. When that timeout ran, it looked for the old ID — gone, nothing written. The second call succeeded but caught the fetch mid-flight. Net result: anywhere between 2 and 4 records visible depending on device speed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix: fixed ID + requestAnimationFrame
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderSection&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;containerId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_sectionContainer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ← fixed, stable ID&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;div id="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;containerId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;Loading...&amp;lt;/div&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;mainArea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// requestAnimationFrame guarantees the DOM is updated&lt;/span&gt;
  &lt;span class="c1"&gt;// before looking up the container — more reliable than setTimeout&lt;/span&gt;
  &lt;span class="nf"&gt;requestAnimationFrame&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;_loadSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;containerId&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;With a fixed ID, any subsequent call always finds the same container, so the last render wins correctly. &lt;code&gt;requestAnimationFrame&lt;/code&gt; runs the callback in the next browser frame, after layout is calculated and the node is guaranteed present — no arbitrary millisecond guess involved.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;General rule:&lt;/strong&gt; &lt;code&gt;setTimeout(fn, N)&lt;/code&gt; to wait for a DOM update is fragile — the right &lt;code&gt;N&lt;/code&gt; depends on device speed. &lt;code&gt;requestAnimationFrame&lt;/code&gt; is deterministic: it fires exactly after the browser finishes rendering the current frame.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Table layout without horizontal scroll
&lt;/h2&gt;

&lt;p&gt;Once the loading bugs were fixed, the table overflowed horizontally. The culprit: &lt;code&gt;white-space: nowrap&lt;/code&gt; on cells combined with generous padding preventing text wrap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Fixed layout with percentage column widths */&lt;/span&gt;
&lt;span class="nt"&gt;table&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;table-layout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Widths declared on the header row */&lt;/span&gt;
&lt;span class="nt"&gt;th&lt;/span&gt;&lt;span class="nd"&gt;:nth-child&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;1&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;11%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;/* Last name */&lt;/span&gt;
&lt;span class="nt"&gt;th&lt;/span&gt;&lt;span class="nd"&gt;:nth-child&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;2&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;/* First name */&lt;/span&gt;
&lt;span class="nt"&gt;th&lt;/span&gt;&lt;span class="nd"&gt;:nth-child&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;3&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;/* Email */&lt;/span&gt;
&lt;span class="nt"&gt;th&lt;/span&gt;&lt;span class="nd"&gt;:nth-child&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;4&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;18%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;/* POS type */&lt;/span&gt;
&lt;span class="nt"&gt;th&lt;/span&gt;&lt;span class="nd"&gt;:nth-child&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;5&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;/* Qty */&lt;/span&gt;
&lt;span class="nt"&gt;th&lt;/span&gt;&lt;span class="nd"&gt;:nth-child&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;6&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;/* Client type */&lt;/span&gt;
&lt;span class="nt"&gt;th&lt;/span&gt;&lt;span class="nd"&gt;:nth-child&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;7&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;/* Plan */&lt;/span&gt;
&lt;span class="nt"&gt;th&lt;/span&gt;&lt;span class="nd"&gt;:nth-child&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;8&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;/* Date */&lt;/span&gt;

&lt;span class="nt"&gt;td&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;word-break&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;break-word&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c"&gt;/* Long text wraps */&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7px&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Only Date and Qty stay single-line */&lt;/span&gt;
&lt;span class="nt"&gt;td&lt;/span&gt;&lt;span class="nd"&gt;:nth-child&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;5&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
&lt;span class="nt"&gt;td&lt;/span&gt;&lt;span class="nd"&gt;:nth-child&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;8&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;white-space&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;nowrap&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;With &lt;code&gt;table-layout: fixed&lt;/code&gt;, the browser distributes widths from the declared header values instead of calculating from cell content — a predictable, stable table with no horizontal scroll.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Always test the Firebase URL in the browser before writing fetch code.&lt;/strong&gt; Opening &lt;code&gt;https://&amp;lt;db&amp;gt;.firebasedatabase.app/&amp;lt;node&amp;gt;.json&lt;/code&gt; directly reveals the node, data structure, and exact key names in seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never guess the key names of an external DB.&lt;/strong&gt; "Intuitive" names seem obvious until the DB uses something else entirely. Always verify from actual data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;setTimeout&lt;/code&gt; to wait for the DOM is fragile; &lt;code&gt;requestAnimationFrame&lt;/code&gt; is deterministic.&lt;/strong&gt; A &lt;code&gt;setTimeout(fn, 50)&lt;/code&gt; "to give the DOM time to update" is a red flag — the delay works on your machine but may not on a slower device or under rapid re-renders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic container IDs are dangerous when re-renders happen.&lt;/strong&gt; An ID based on &lt;code&gt;Date.now()&lt;/code&gt; generates a new identifier every call; any code referencing the "old" ID hits a dead end. Fixed, semantic IDs eliminate this entire bug class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;table-layout: fixed&lt;/code&gt; with percentage widths&lt;/strong&gt; is the right call for responsive tables — letting the browser calculate from content leads to unpredictable results.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Full write-up on my blog: &lt;a href="https://roversia.it/blog-05-paypos-firebase-admin.html" rel="noopener noreferrer"&gt;roversia.it/blog-05-paypos-firebase-admin.html&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>firebase</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Building a Notification Center in a Firebase PWA — Firestore vs RTDB and Three Bootstrap Fallback Levels</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Thu, 02 Jul 2026 09:00:02 +0000</pubDate>
      <link>https://dev.to/androve2k/building-a-notification-center-in-a-firebase-pwa-firestore-vs-rtdb-and-three-bootstrap-fallback-5feb</link>
      <guid>https://dev.to/androve2k/building-a-notification-center-in-a-firebase-pwa-firestore-vs-rtdb-and-three-bootstrap-fallback-5feb</guid>
      <description>&lt;p&gt;The control panel I use to coordinate a sales team already had FCM push notifications, DM and department group chats, timed reminders with sound, leave/permission requests, and a shared calendar. Each system worked fine on its own — but they were separate islands.&lt;/p&gt;

&lt;p&gt;If an operator wanted to know whether their leave request had been approved, they navigated to the leave section. For messages, they opened chat. For triggered reminders, they dug through a global log looking for their own entries among everyone else's. No unified place to check.&lt;/p&gt;

&lt;p&gt;The idea: a 🔔 bell icon showing a personal timeline of everything relevant to the current user — a log filtered to them, browsable weeks later. No extra push, just a structured, always-available archive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stack:&lt;/strong&gt; Firebase Realtime Database (chat, calendar, reminders), Firestore (&lt;code&gt;activityLog&lt;/code&gt; for leave/permissions), vanilla JavaScript. Single-page PWA, no framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: six categories, two databases
&lt;/h2&gt;

&lt;p&gt;The panel already uses both RTDB and Firestore, and each is a better fit for different kinds of data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Firestore for leave and permissions
&lt;/h3&gt;

&lt;p&gt;Leave and permission requests already live in Firestore's &lt;code&gt;activityLog&lt;/code&gt; collection — historical records that don't change after the fact. Using &lt;code&gt;.get()&lt;/code&gt; (single read) instead of &lt;code&gt;.onSnapshot()&lt;/code&gt; (real-time listener) loads history once on panel open, keeping read cost low.&lt;/p&gt;

&lt;p&gt;The query has three conditions — operator, event type, and a 90-day cutoff — which requires a composite index. Firestore's console shows a direct link to create it automatically the first time the query runs.&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;cutoff&lt;/span&gt; &lt;span class="o"&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="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;90&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;60&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tipi&lt;/span&gt; &lt;span class="o"&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;ferie_richiesta&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;ferie_approvata&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;ferie_rifiutata&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;ferie_annullata&lt;/span&gt;&lt;span class="dl"&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="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;activityLog&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;operatore&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;==&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currentUser&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tipo&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;in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tipi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ts&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;&amp;gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;)&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="c1"&gt;// single read, not real-time&lt;/span&gt;
  &lt;span class="p"&gt;.&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;snap&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;docs&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;d&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;d&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="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&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;h3&gt;
  
  
  Why RTDB for calendar, reminders, and chat
&lt;/h3&gt;

&lt;p&gt;Calendar, reminders, and chat live on RTDB, whose small nodes and existing structure are already optimized for fast reads. Attaching a &lt;code&gt;.on('value')&lt;/code&gt; listener to an existing node adds no significant cost.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Calendar&lt;/strong&gt; — filters events with a &lt;code&gt;firedAt&lt;/code&gt; field where the creator matches the current user or the department matches theirs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reminders&lt;/strong&gt; — &lt;code&gt;reminders/{opKey}&lt;/code&gt; holds only that operator's personal reminders; only entries with &lt;code&gt;fired: true&lt;/code&gt; show (it's a history, not a to-do list).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chat&lt;/strong&gt; — DMs from &lt;code&gt;chat/dm/{myKey}&lt;/code&gt; with &lt;code&gt;limitToLast(100)&lt;/code&gt;, filtering out self-sent messages. Groups only attach for the departments the user belongs to, last 60 messages each.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Rule of thumb:&lt;/strong&gt; Firestore &lt;code&gt;.get()&lt;/code&gt; for historical data that doesn't change after being written; RTDB &lt;code&gt;.on('value')&lt;/code&gt; for structurally small, reactive nodes. Getting this backwards shows up immediately in the Firebase console bill.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;Node / Query&lt;/th&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🏖️ Leave&lt;/td&gt;
&lt;td&gt;Firestore&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;activityLog&lt;/code&gt; (type ferie_*)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.get()&lt;/code&gt; — single&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🔵 Permissions&lt;/td&gt;
&lt;td&gt;Firestore&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;activityLog&lt;/code&gt; (type permesso/malattia)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.get()&lt;/code&gt; — single&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;📅 Calendar&lt;/td&gt;
&lt;td&gt;RTDB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;calendario/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.on('value')&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⏰ Reminders&lt;/td&gt;
&lt;td&gt;RTDB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;reminders/{opKey}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.on('value')&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;💬 DM Chat&lt;/td&gt;
&lt;td&gt;RTDB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;chat/dm/{myKey}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.on('child_added')&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;💬 Group Chat&lt;/td&gt;
&lt;td&gt;RTDB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;chat/groups/{gk}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.on('child_added')&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The UI: drawer, categories, and badge
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The panel as a "portal"
&lt;/h3&gt;

&lt;p&gt;The notification center is a slide-in panel from the right with a semi-transparent overlay. It's placed as a direct sibling of &lt;code&gt;#mainArea&lt;/code&gt; in the DOM rather than nested inside an intermediate container — deliberately, since any ancestor with &lt;code&gt;transform&lt;/code&gt; or &lt;code&gt;overflow:hidden&lt;/code&gt; breaks &lt;code&gt;position:fixed&lt;/code&gt; in its children. Appending directly to &lt;code&gt;document.body&lt;/code&gt; solves it for good.&lt;/p&gt;

&lt;h3&gt;
  
  
  Filters on two rows
&lt;/h3&gt;

&lt;p&gt;The category bar has six filterable chips: All, Chat, Calendar, Reminders, Leave, Permissions. The first version used &lt;code&gt;overflow-x: auto&lt;/code&gt; for horizontal mobile scrolling — chips would vanish off-screen with no indication you could scroll for more. Switching to &lt;code&gt;flex-wrap: wrap&lt;/code&gt; made chips wrap onto two rows automatically. A one-line fix with a real usability difference.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Unread" badge and per-operator localStorage
&lt;/h3&gt;

&lt;p&gt;The bell's numeric badge counts unopened items. Read state must persist across sessions but also stay separate per user on the same machine. Solution: &lt;code&gt;localStorage&lt;/code&gt; keyed as &lt;code&gt;nc_read_{userKey}&lt;/code&gt;, each operator with their own set of already-read IDs.&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;function&lt;/span&gt; &lt;span class="nf"&gt;_ncGetRead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userKey&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;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&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;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nc_read_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;userKey&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;[]&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;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&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;function&lt;/span&gt; &lt;span class="nf"&gt;_ncMarkRead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;itemId&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="kd"&gt;set&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_ncGetRead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;set&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;itemId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nc_read_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;userKey&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="kd"&gt;set&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;_ncUpdateBadge&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;read&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_ncGetRead&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;currentUser&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;unread&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_ncItems&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;item&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;read&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;item&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;length&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;badge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notifBadge&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;badge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;badge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;unread&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;99+&lt;/span&gt;&lt;span class="dl"&gt;'&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;unread&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;h2&gt;
  
  
  The bootstrap problem: three fallback levels
&lt;/h2&gt;

&lt;p&gt;The notification center worked perfectly in testing. In production, several operators who came in via "remember me" found the panel empty.&lt;/p&gt;

&lt;p&gt;Same root cause I'd already hit with the calendar: &lt;code&gt;_restoreLoginSession()&lt;/code&gt; wasn't calling &lt;code&gt;_initNotifCenter()&lt;/code&gt;. But this time with an extra wrinkle — &lt;code&gt;_initNotifCenter&lt;/code&gt; is defined inside the notification center's own JS block, which loads after the core boots, so there's no guarantee it even exists at the exact moment session restore completes.&lt;/p&gt;

&lt;p&gt;Three cascading fallback levels:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Wrapper on &lt;code&gt;_initFCM&lt;/code&gt;&lt;/strong&gt; — on manual login, the core calls &lt;code&gt;_initFCM(userName)&lt;/code&gt;. A wrapper intercepts it and also fires &lt;code&gt;_initNotifCenter&lt;/code&gt;. Covers 100% of manual logins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polling every 500ms × 40 attempts&lt;/strong&gt; — checks whether &lt;code&gt;state.currentUser&lt;/code&gt; is set and the center isn't already initialized. Covers session restore regardless of timing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MutationObserver on &lt;code&gt;#navUserChip&lt;/code&gt;&lt;/strong&gt; — watches the username chip's visibility; triggers init when it appears, and calls &lt;code&gt;_destroyNotifCenter()&lt;/code&gt; on logout (chip hidden) to detach all listeners and reset state.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_ncBootstrap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&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;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&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="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;attempts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iv&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="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;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;currentUser&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_ncInitialized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="c1"&gt;// User is already logged in (session restore): start immediately&lt;/span&gt;
      &lt;span class="nf"&gt;_initNotifCenter&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;currentUser&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="mi"&gt;500&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;Idempotency is non-negotiable here: with three separate startup paths, &lt;code&gt;_initNotifCenter&lt;/code&gt; must check &lt;code&gt;window._ncInitialized&lt;/code&gt; to block duplicate Firebase listeners if two levels fire near-simultaneously.&lt;/p&gt;

&lt;h2&gt;
  
  
  Positioning the bell bubble: a bumpy ride
&lt;/h2&gt;

&lt;p&gt;The bell UI took more iterations than expected — not for technical complexity, but because every placement that looked right on desktop broke on mobile.&lt;/p&gt;

&lt;p&gt;Version one put it in the desktop sidebar — vanished on mobile along with the sidebar. Version two used a &lt;code&gt;position:fixed&lt;/code&gt; floating bubble bottom-right, same pattern as existing Chat and AI buttons — worked, but looked like a fourth floating button crowding an already busy corner.&lt;/p&gt;

&lt;p&gt;The final version moves it &lt;strong&gt;top-right&lt;/strong&gt; (&lt;code&gt;top:18px; right:18px&lt;/code&gt;), mirroring the structural pattern of &lt;code&gt;#chatWrapper&lt;/code&gt; and &lt;code&gt;#aiWrapper&lt;/code&gt;. On screens under 1024px it lands exactly on the topbar's user chip and hamburger button, so a media query pushes it to &lt;code&gt;top:66px&lt;/code&gt; on mobile.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Desktop: top-right corner */&lt;/span&gt;
&lt;span class="nf"&gt;#notifWrapper&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;18px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;18px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;z-index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;999992&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Mobile/tablet: drop below the topbar */&lt;/span&gt;
&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1023px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;#notifWrapper&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;66px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c"&gt;/* 60px topbar + 6px margin */&lt;/span&gt;
    &lt;span class="nl"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;14px&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;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Firestore &lt;code&gt;.get()&lt;/code&gt; for historical data, RTDB &lt;code&gt;.on()&lt;/code&gt; for reactive nodes.&lt;/strong&gt; Not everything needs to be real-time — a single Firestore read is far cheaper than a permanent open listener for data that doesn't change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session restore is a separate code path.&lt;/strong&gt; As a developer you almost always use manual login; as a PWA user you almost always use "remember me." Anything triggered on login needs to also fire on restore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three bootstrap levels isn't overkill&lt;/strong&gt; when initialization timing is non-deterministic. A direct wrapper, a polling fallback, and a MutationObserver safety net, with an idempotency flag, cover all practical cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;flex-wrap: wrap&lt;/code&gt; beats &lt;code&gt;overflow-x: auto&lt;/code&gt;&lt;/strong&gt; for mobile filter chips. Hidden horizontal scroll is invisible to users; two wrapped rows aren't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;position:fixed&lt;/code&gt; bubbles on mobile PWAs compete with the topbar.&lt;/strong&gt; Every fixed top element needs its own mobile media query.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Full write-up on my blog: &lt;a href="https://roversia.it/blog-04-centro-notifiche-pwa.html" rel="noopener noreferrer"&gt;roversia.it/blog-04-centro-notifiche-pwa.html&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>firebase</category>
      <category>pwa</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Shared Calendar in a Firebase PWA — FCM Push and the Silent Session-Restore Bug</title>
      <dc:creator>Andrea Roversi</dc:creator>
      <pubDate>Thu, 02 Jul 2026 08:58:56 +0000</pubDate>
      <link>https://dev.to/androve2k/shared-calendar-in-a-firebase-pwa-fcm-push-and-the-silent-session-restore-bug-1f3c</link>
      <guid>https://dev.to/androve2k/shared-calendar-in-a-firebase-pwa-fcm-push-and-the-silent-session-restore-bug-1f3c</guid>
      <description>&lt;p&gt;The control panel for my sales team already had automatic reminders and an internal chat. The next step was a shared calendar: any operator sets a personal or department appointment, and every colleague in that department gets an alert — with sound — at the scheduled time, even if the panel is open on a different page.&lt;/p&gt;

&lt;p&gt;The existing notification stack already used Firebase Realtime Database, FCM for background push, and &lt;code&gt;AudioContext&lt;/code&gt; for in-app sound. The calendar had to slot into this without adding new dependencies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stack:&lt;/strong&gt; Firebase Realtime Database (&lt;code&gt;calendario/&lt;/code&gt;), FCM v1 for push, Web Audio API &lt;code&gt;AudioContext&lt;/code&gt; for in-app sound, vanilla JavaScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the calendar: grid, modal, visibility
&lt;/h2&gt;

&lt;p&gt;The entry point is a dashboard button opening a modal with a monthly grid — &lt;code&gt;‹ ›&lt;/code&gt; month navigation, a legend with colored dots (green personal, blue department), and a &lt;strong&gt;+ Appointment&lt;/strong&gt; button.&lt;/p&gt;

&lt;p&gt;Every appointment saved to Firebase carries a &lt;code&gt;reparto&lt;/code&gt; (department) field set to the creator's department ID. When an operator opens the calendar, &lt;code&gt;_loadCalendario()&lt;/code&gt; reads the full &lt;code&gt;calendario/&lt;/code&gt; tree and filters: only the user's personal appointments and their department's appointments are shown.&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;function&lt;/span&gt; &lt;span class="nf"&gt;_isVisible&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="nx"&gt;currentUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userRep&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tipo&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;personale&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;creatore&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;currentUser&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tipo&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reparto&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reparto&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;userRep&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="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;When the Firebase listener receives an appointment, &lt;code&gt;_scheduleAppuntamento()&lt;/code&gt; calculates the delay in milliseconds and arms a &lt;code&gt;setTimeout&lt;/code&gt;. At trigger time, a full-screen overlay appears with a triple-chime and a "✓ Seen" button.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;calPushSent&lt;/code&gt; flag written to Firebase at send time prevents multiple clients in the same department from each firing the same FCM push for the same appointment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The notification problem: nobody was getting anything
&lt;/h2&gt;

&lt;p&gt;After the first deploy, tests looked fine. But in real team use, when someone set a department appointment, no other operator received sound or alert — only manually opening the calendar modal showed it. The creator got everything; everyone else got nothing.&lt;/p&gt;

&lt;p&gt;Initial suspects: department ID mismatch, browser throttling for background tabs, &lt;code&gt;AudioContext&lt;/code&gt; blocked by autoplay policy. All plausible. None of them were the real problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  The diagnosis: the listener never started
&lt;/h3&gt;

&lt;p&gt;The panel has two distinct access flows: manual login (email + password) and &lt;strong&gt;session restore&lt;/strong&gt; — the flow that fires when a user checked "remember me" and reopens the PWA without re-entering credentials.&lt;/p&gt;

&lt;p&gt;In manual login, &lt;code&gt;_loadCalendario()&lt;/code&gt; and &lt;code&gt;_initFCM()&lt;/code&gt; were called correctly. In &lt;code&gt;_restoreLoginSession()&lt;/code&gt;, those two calls simply weren't there.&lt;/p&gt;

&lt;p&gt;Every operator using the installed PWA with a saved session never had the calendar's Firebase listener active. No timers were ever armed, and the FCM token never refreshed. The calendar only populated when opening the modal manually, because &lt;code&gt;openCalendarioModal()&lt;/code&gt; calls &lt;code&gt;_loadCalendario()&lt;/code&gt; internally — exactly the symptom observed.&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;if &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="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;currentUser&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="c1"&gt;// ... other setup ...&lt;/span&gt;

  &lt;span class="c1"&gt;// ── FIX: start calendar and FCM in session restore too ──────────&lt;/span&gt;
  &lt;span class="c1"&gt;// Without these calls, "remember me" users never have&lt;/span&gt;
  &lt;span class="c1"&gt;// an active Firebase listener or an updated FCM token.&lt;/span&gt;
  &lt;span class="k"&gt;if &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;_loadCalendario&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&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;_loadCalendario&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;800&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_initFCM&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_initFCM&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="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1500&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;_loadCalendario()&lt;/code&gt; is idempotent — it detaches the previous listener before reattaching — so calling it twice is safe. Worth verifying before adding calls like this to a restore flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliable sound: a shared AudioContext
&lt;/h2&gt;

&lt;p&gt;The second problem: sound wouldn't play when returning to the panel after the tab had been backgrounded. Browsers suspend the &lt;code&gt;AudioContext&lt;/code&gt; of non-visible tabs and block audio playback not unlocked by an explicit user gesture.&lt;/p&gt;

&lt;p&gt;The fix: maintain a single shared &lt;code&gt;AudioContext&lt;/code&gt; (&lt;code&gt;window._pcAudioCtx&lt;/code&gt;) unlocked on the first user gesture, and reactivated on &lt;code&gt;visibilitychange&lt;/code&gt; when the tab returns to the foreground — a case browsers generally treat as legitimate.&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;function&lt;/span&gt; &lt;span class="nf"&gt;_pcUnlockAudio&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_pcAudioCtx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_pcAudioCtx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AudioContext&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webkitAudioContext&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_pcAudioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;suspended&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_pcAudioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&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="kd"&gt;function&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="nx"&gt;e&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pointerdown&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;keydown&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;touchstart&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;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_pcUnlockAudio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;passive&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visibilitychange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;function&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;visibilityState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visible&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;_pcUnlockAudio&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;h2&gt;
  
  
  Push notifications with system sound
&lt;/h2&gt;

&lt;p&gt;For sound when the PWA isn't in the foreground, FCM push notifications must explicitly declare sound in the platform-specific payload sections — otherwise iOS pushes arrive completely silent.&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;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;webpush&lt;/span&gt;&lt;span class="p"&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="na"&gt;Urgency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;:&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="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;silent&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;requireInteraction&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;android&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HIGH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sound&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;default_sound&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="c1"&gt;// iOS (without aps.sound the push arrives SILENT)&lt;/span&gt;
    &lt;span class="na"&gt;apns&lt;/span&gt;&lt;span class="p"&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;apns-priority&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;10&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;aps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sound&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default&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;content-available&lt;/span&gt;&lt;span class="dl"&gt;'&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="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;strData&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;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Two access flows, two sets of initializations.&lt;/strong&gt; In a PWA with "remember me", manual login and session restore are separate code paths. Any service started on login must also start on restore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared AudioContext, unlocked on first gesture.&lt;/strong&gt; A fresh &lt;code&gt;AudioContext&lt;/code&gt; on every playback risks being suspended when returning from background. One shared context, unlocked on &lt;code&gt;pointerdown&lt;/code&gt; and &lt;code&gt;visibilitychange&lt;/code&gt;, covers most real cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background sound needs system sound.&lt;/strong&gt; &lt;code&gt;AudioContext&lt;/code&gt; only works while the page is open. For sound when the PWA is closed or backgrounded, FCM needs &lt;code&gt;android&lt;/code&gt; and &lt;code&gt;apns&lt;/code&gt; blocks in the payload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bootstrap bugs hide from your own testing.&lt;/strong&gt; Developers almost always log in manually — session restore is what everyone else uses. Test both flows explicitly, with separate accounts, before deploying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;position:fixed&lt;/code&gt; overlays in multi-layer PWAs&lt;/strong&gt; need a higher &lt;code&gt;z-index&lt;/code&gt; than anything else, must be appended directly to &lt;code&gt;document.body&lt;/code&gt; (not inside a &lt;code&gt;transform&lt;/code&gt;-ed container), and should use &lt;code&gt;inset:0&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Full write-up on my blog: &lt;a href="https://roversia.it/blog-03-calendario-firebase-pwa.html" rel="noopener noreferrer"&gt;roversia.it/blog-03-calendario-firebase-pwa.html&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>firebase</category>
      <category>pwa</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
