<?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: cometa-kaito</title>
    <description>The latest articles on DEV Community by cometa-kaito (@cometakaito).</description>
    <link>https://dev.to/cometakaito</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%2F4191440%2F4f7e179f-b8fa-446c-899e-22434cd183e3.png</url>
      <title>DEV Community: cometa-kaito</title>
      <link>https://dev.to/cometakaito</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cometakaito"/>
    <language>en</language>
    <item>
      <title>Bing Webmaster UrlCount Stuck at 1: the sitemap-index Trap</title>
      <dc:creator>cometa-kaito</dc:creator>
      <pubDate>Sun, 08 Nov 2026 02:22:45 +0000</pubDate>
      <link>https://dev.to/cometakaito/bing-webmaster-urlcount-stuck-at-1-the-sitemap-index-trap-4i4i</link>
      <guid>https://dev.to/cometakaito/bing-webmaster-urlcount-stuck-at-1-the-sitemap-index-trap-4i4i</guid>
      <description>&lt;h2&gt;
  
  
  Conclusion first
&lt;/h2&gt;

&lt;p&gt;This blog (tech.rebounder.jp) publishes 3-4 posts a day. If it took search engines days to weeks to come crawl, anything written would just sit unread until they showed up. Two mechanisms close that gap: &lt;strong&gt;IndexNow&lt;/strong&gt;, which tells Bing and Yandex "this changed" the instant it happens, and the &lt;strong&gt;Bing Webmaster API&lt;/strong&gt;, which proactively registers the sitemap and individual URLs.&lt;/p&gt;

&lt;p&gt;But "registered" and "verified" turned out not to mean "indexed." Bing treated this site as a single-page site for three days. The cause: only &lt;code&gt;sitemap-index.xml&lt;/code&gt; — the table of contents for the sitemaps — had ever been submitted.&lt;/p&gt;

&lt;h2&gt;
  
  
  IndexNow alone isn't enough
&lt;/h2&gt;

&lt;p&gt;IndexNow is a free protocol that only requires publishing one key file to instantly tell Bing, Yandex, and others "this URL changed." No account, no cost. tech.rebounder.jp pulls its recent changes from &lt;code&gt;git log&lt;/code&gt; (which files were committed) and notifies on exactly the URLs whose Markdown actually diffed.&lt;/p&gt;

&lt;p&gt;The catch: &lt;strong&gt;Google doesn't support it.&lt;/strong&gt; IndexNow only speeds things up on the Bing/Yandex side; Google still has to discover pages the old way, through sitemaps and internal links. Since ChatGPT's search runs on Bing's index, IndexNow ends up mattering less for Google and more directly for LLM exposure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bing Webmaster counts UrlCount as 1 and stops
&lt;/h2&gt;

&lt;p&gt;IndexNow is a notification, not a registration. For Bing to understand the site's overall structure, the sitemap still has to be registered separately through Bing Webmaster.&lt;/p&gt;

&lt;p&gt;tech.rebounder.jp registered with Bing Webmaster on 2026-09-01 and showed &lt;code&gt;IsVerified: true&lt;/code&gt;. But &lt;code&gt;GetCrawlStats&lt;/code&gt; and &lt;code&gt;GetQueryStats&lt;/code&gt; stayed empty, and impressions sat at zero.&lt;/p&gt;

&lt;p&gt;The cause was that only &lt;code&gt;sitemap-index.xml&lt;/code&gt; — the index file that just links out to the child sitemaps — had been registered. When Bing fetches that index through the API, it records &lt;code&gt;UrlCount: 1&lt;/code&gt; and stops there. The index file is itself just one XML file, so from Bing's side it looks like "a site with one URL registered." &lt;code&gt;IsVerified: true&lt;/code&gt; only meant "we confirmed you own this site" — it never meant "we know what's on it."&lt;/p&gt;

&lt;p&gt;The fix was to submit the child sitemaps (the ones that actually list article URLs, like &lt;code&gt;sitemap-0.xml&lt;/code&gt;) directly via &lt;code&gt;SubmitFeed&lt;/code&gt;. The moment that happened, &lt;code&gt;UrlCount&lt;/code&gt; jumped from &lt;strong&gt;1 to 203&lt;/strong&gt;, and the site was crawled that same day. For three days, Bing had effectively believed this was a one-page site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day-to-day operation: register the sitemap and submit URLs, both
&lt;/h2&gt;

&lt;p&gt;There's no need to redo everything from scratch each time. A script called &lt;code&gt;push:bing&lt;/code&gt; automates two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Incremental sitemap registration&lt;/strong&gt; — diffs the already-registered feed list (&lt;code&gt;GetFeeds&lt;/code&gt;) against the child sitemap URLs listed in the post-build &lt;code&gt;dist/sitemap-index.xml&lt;/code&gt;, and calls &lt;code&gt;SubmitFeed&lt;/code&gt; only for the ones not yet registered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Submitting recent URLs individually&lt;/strong&gt; — builds URLs from Markdown files committed within &lt;code&gt;git log --since=&amp;lt;N&amp;gt; days&lt;/code&gt;, and sends them with &lt;code&gt;SubmitUrlbatch&lt;/code&gt;, checking Bing's daily quota (&lt;code&gt;GetUrlSubmissionQuota&lt;/code&gt;) first so submissions don't exceed it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result gets checked against reality at the end. It re-fetches the feed list with &lt;code&gt;GetFeeds&lt;/code&gt; and treats anything whose &lt;code&gt;Status&lt;/code&gt; isn't &lt;code&gt;Success&lt;/code&gt; as a failure. It also &lt;strong&gt;treats the total number of URLs Bing knows about as a failure if it's 1 or fewer&lt;/strong&gt; — a guard against quietly treating 0 or 1 as a success, which is exactly the "only the index got through" state that caused the original problem. This is a mechanical check for the same failure mode that started it all.&lt;/p&gt;

&lt;h2&gt;
  
  
  A second trap: treating a rename as delete-plus-add
&lt;/h2&gt;

&lt;p&gt;Both IndexNow and the Bing Webmaster API decide which URLs to submit by pulling "recently changed Markdown files" from &lt;code&gt;git log --name-only&lt;/code&gt;. That command has a quirk: &lt;strong&gt;it reports a file rename as a delete-plus-add pair.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On the day an article slug actually got renamed, the old slug (which 404s in production) got swept into the submission batch right alongside the new one. One out of 15 URLs submitted that day pointed to something that didn't exist. Sending 404s doesn't just waste quota — it also erodes how much a search engine trusts the submission feed as a whole.&lt;/p&gt;

&lt;p&gt;The fix was straightforward: keep only the paths from &lt;code&gt;git log&lt;/code&gt; that actually exist in the working tree, checked with &lt;code&gt;existsSync&lt;/code&gt;. "Was committed" and "currently exists" are different facts, and only the first one had been checked. The fix also logs how many files were dropped this way, so a silent reduction doesn't go unnoticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost
&lt;/h2&gt;

&lt;p&gt;Both IndexNow and the Bing Webmaster API are free — no account fee, no monthly charge. URL submissions stay well within the daily quota of 100 and the monthly quota of 2,700, so tech.rebounder.jp's publishing pace (3-4 posts a day) runs with no billing risk at all. The only real cost is operational care: keeping the API key in &lt;code&gt;.env.local&lt;/code&gt; and out of both build artifacts and the repository's public surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;"Registered the site" and "confirmed as owner" turned out not to be the same thing as "the search engine knows what's on it." Bing Webmaster treated a table-of-contents file (&lt;code&gt;sitemap-index.xml&lt;/code&gt;) as &lt;code&gt;UrlCount: 1&lt;/code&gt; and stayed there until the child sitemaps were submitted directly. On top of that, deciding which URLs to submit mechanically is fragile against an everyday operation like a rename. Both problems were only visible by &lt;strong&gt;checking the actual resulting numbers against the assumption that registering something means the search engine understood it.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tech.rebounder.jp/en/posts/bing-webmaster-urlcount-stuck-sitemap-index/" rel="noopener noreferrer"&gt;tech.rebounder.jp&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>tooling</category>
      <category>productivity</category>
      <category>programming</category>
    </item>
    <item>
      <title>vercel inspect won't show which commit is live</title>
      <dc:creator>cometa-kaito</dc:creator>
      <pubDate>Sat, 07 Nov 2026 02:24:19 +0000</pubDate>
      <link>https://dev.to/cometakaito/vercel-inspect-wont-show-which-commit-is-live-4ml4</link>
      <guid>https://dev.to/cometakaito/vercel-inspect-wont-show-which-commit-is-live-4ml4</guid>
      <description>&lt;h2&gt;
  
  
  Starting with the conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When production is deployed straight from the Vercel CLI with &lt;code&gt;vercel --prod&lt;/code&gt; instead of GitHub integration, the deployment is never linked to a git commit. If two people run &lt;code&gt;vercel --prod&lt;/code&gt; at the same time, the later one silently overwrites the other — and checking &lt;code&gt;vercel inspect&lt;/code&gt; afterward gives you no way to tell which build (and which commit) is actually live.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom
&lt;/h2&gt;

&lt;p&gt;On the morning of 2026-09-16, two separate pieces of work ran &lt;code&gt;vercel --prod&lt;/code&gt; against the same Vercel project at the same time, colliding three times.&lt;/p&gt;

&lt;p&gt;This project's deployment isn't triggered by a GitHub push integration — it's a local &lt;code&gt;vercel --prod&lt;/code&gt; that uploads a build directly. That means every deploy reaches production instantly without anyone having to think about git state. The tradeoff: &lt;strong&gt;Vercel is never told which commit the build came from in the first place.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Running &lt;code&gt;vercel inspect&lt;/code&gt; against the production deployment confirms this: the output lists the deployment ID, project name, state, creation time, the set of aliases, and the build artifacts — nothing resembling a git commit hash or commit message. This isn't a one-off rendering glitch from that incident; checking a CLI-uploaded deployment today still shows nothing about which commit it came from.&lt;/p&gt;

&lt;p&gt;So when the two builds collided three times, there was no way afterward to look at &lt;code&gt;vercel inspect&lt;/code&gt; and work out "which change is live right now." We couldn't even safely assume our own change had landed. The only way to re-establish certainty was to redeploy from the latest commit on the main branch, which meant two redeploys that shouldn't have been necessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause
&lt;/h2&gt;

&lt;p&gt;The root cause: &lt;strong&gt;choosing a CLI-upload workflow instead of GitHub integration means the mechanism that ties a deployment to a git commit simply doesn't exist.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A GitHub-integrated deployment is built in response to a detected push, so Vercel can always record which commit it came from. A CLI upload, by contrast, is just a channel for sending a local build artifact — there's no standard way for it to tell Vercel which commit's working tree produced that artifact.&lt;/p&gt;

&lt;p&gt;Because this project uses the CLI-upload workflow, Vercel had no material to distinguish between concurrent deploys from the start. Last-write-wins overwriting can happen with ordinary deployments too, but &lt;strong&gt;with GitHub integration the commit record lets you confirm which one is live now. With CLI upload, that information never existed, so there's no way to check it after the fact&lt;/strong&gt; — that's the real difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix
&lt;/h2&gt;

&lt;p&gt;To fix this without switching integration methods or touching the CLI workflow, we made no code change. Instead we wrote down an operational step: &lt;strong&gt;before running a deploy, confirm no other work is about to deploy at the same time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Following that check on every deploy prevents the situation where two &lt;code&gt;vercel --prod&lt;/code&gt; runs happen at once in the first place. Rather than trying to make &lt;code&gt;vercel inspect&lt;/code&gt; distinguishable after the fact, we're &lt;strong&gt;preventing the collision before it happens.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Prevention
&lt;/h2&gt;

&lt;p&gt;This incident caused no harm to production customers. Both of the colliding runs were from the same person (the same operator), so it resolved with two extra redeploys to re-establish certainty.&lt;/p&gt;

&lt;p&gt;If you choose CLI-upload deployment over GitHub integration, you need to go in knowing that &lt;strong&gt;the assumption "I can check git state after deploying" does not hold&lt;/strong&gt;. This isn't a Vercel bug — it's a capability that never existed once you picked that integration method, and it can't be solved by adding an after-the-fact check. &lt;strong&gt;The only real fix is eliminating the possibility of a concurrent run before it happens.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tech.rebounder.jp/en/posts/vercel-cli-deploy-concurrent-collision-inspect-blind/" rel="noopener noreferrer"&gt;tech.rebounder.jp&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>tooling</category>
      <category>productivity</category>
      <category>programming</category>
    </item>
    <item>
      <title>PostgREST .or() Breaks Silently on a Comma</title>
      <dc:creator>cometa-kaito</dc:creator>
      <pubDate>Fri, 06 Nov 2026 02:22:35 +0000</pubDate>
      <link>https://dev.to/cometakaito/postgrest-or-breaks-silently-on-a-comma-27ek</link>
      <guid>https://dev.to/cometakaito/postgrest-or-breaks-silently-on-a-comma-27ek</guid>
      <description>&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Interpolating a raw search term into a PostgREST &lt;code&gt;.or()&lt;/code&gt; filter via a template literal breaks the filter grammar on a reserved character like a comma.&lt;/strong&gt; On top of that, &lt;code&gt;supabase-js&lt;/code&gt; doesn't throw on a transport or PostgREST failure — it represents it as a &lt;code&gt;{data: null, error}&lt;/code&gt; return value — so the moment the caller reads &lt;code&gt;.data ?? []&lt;/code&gt;, a broken filter that turns &lt;code&gt;data&lt;/code&gt; into &lt;code&gt;null&lt;/code&gt; gets treated as an empty array with no exception and no log. Global search's contacts query broke exactly this way. The fix was to split name and email into separate parameterized &lt;code&gt;.ilike()&lt;/code&gt; calls and merge the results in JS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state it could become
&lt;/h2&gt;

&lt;p&gt;The subject is global search in kimiteras-portal — the source behind both search and the command palette. It queries companies, contacts, projects, contracts, schools, tasks, and documents in one &lt;code&gt;Promise.all&lt;/code&gt; round trip. Contacts alone need to match on both name and email, and before the fix it was written like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;supabase&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contacts&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;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id, name, email, company_id, school_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`name.ilike.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;like&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,email.ilike.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;like&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;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;like&lt;/code&gt; is the search term formatted into &lt;code&gt;%...%&lt;/code&gt;, and wildcard characters (&lt;code&gt;%&lt;/code&gt;, &lt;code&gt;_&lt;/code&gt;, &lt;code&gt;\&lt;/code&gt;) were escaped. What wasn't escaped was the delimiter &lt;code&gt;.or()&lt;/code&gt; itself uses: the comma. The result was handled the same way as every other category:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
  &lt;span class="nx"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Contact&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&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;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;c&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;)`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first argument to &lt;code&gt;.or()&lt;/code&gt; is a PostgREST-specific grammar string, &lt;code&gt;name.ilike.&amp;lt;value&amp;gt;,email.ilike.&amp;lt;value&amp;gt;&lt;/code&gt;, where a comma is interpreted as a condition separator. If the search term itself contains a comma — someone typing a "Last, First" style name, or pasting a name straight off a business card — the generated string splits into extra conditions for every comma in it, and PostgREST can no longer parse it as a valid filter.&lt;/p&gt;

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

&lt;p&gt;What made this worse was how the failure surfaced. &lt;code&gt;supabase-js&lt;/code&gt; query methods are designed not to reject their promise — a transport error and a PostgREST error response are both represented as the same &lt;code&gt;{data, error}&lt;/code&gt; shape. This code read only &lt;code&gt;contacts.data ?? []&lt;/code&gt; and didn't even destructure &lt;code&gt;error&lt;/code&gt;. When the broken filter made PostgREST return an error, &lt;code&gt;data&lt;/code&gt; simply became &lt;code&gt;null&lt;/code&gt;, no exception was thrown, and &lt;code&gt;?? []&lt;/code&gt; quietly treated it as a valid "zero contacts matched" result. Every other category — companies, projects, contracts — still matched fine, so the UI just looked like "a slightly thin result set," with no way to notice that the contacts category alone was vanishing for specific search terms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it
&lt;/h2&gt;

&lt;p&gt;Name and email were split into separate parameterized &lt;code&gt;.ilike()&lt;/code&gt; queries, with the results merged and deduplicated in JS.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Query name and email separately with parameterized .ilike and merge in JS. Interpolating&lt;/span&gt;
&lt;span class="c1"&gt;// raw input into .or() lets a reserved PostgREST character like a comma break the filter&lt;/span&gt;
&lt;span class="c1"&gt;// grammar, silently dropping the contacts category (e.g. q="Last, First"). .ilike binds its&lt;/span&gt;
&lt;span class="c1"&gt;// value as a parameter, so reserved characters are safe.&lt;/span&gt;
&lt;span class="nx"&gt;supabase&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contacts&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;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id, name, email, company_id, school_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ilike&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;like&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="nx"&gt;supabase&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contacts&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;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id, name, email, company_id, school_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ilike&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="nx"&gt;like&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.ilike()&lt;/code&gt; binds its second argument as a parameter, so a value containing a comma or &lt;code&gt;.&lt;/code&gt; — reserved PostgREST characters — doesn't break the filter grammar itself. Because a name match and an email match can return the same contact twice, the merge side uses a &lt;code&gt;Set&lt;/code&gt; to push each ID into &lt;code&gt;hits&lt;/code&gt; only once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seenContact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[...(&lt;/span&gt;&lt;span class="nx"&gt;contactsByName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]),&lt;/span&gt; &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;contactsByEmail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seenContact&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// dedupe a contact matched on both name and email&lt;/span&gt;
  &lt;span class="nx"&gt;seenContact&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;c&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;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="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;To prevent a repeat, a static-guard test now bans raw interpolation into &lt;code&gt;.or()&lt;/code&gt; outright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;does not interpolate the search query raw into PostgREST .or() filter grammar (injection prevention)&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.or(`&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ilike.${&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the test inspects the source string directly, CI fails the moment anyone reintroduces a template literal into &lt;code&gt;.or()&lt;/code&gt; for the same reason, no matter why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;?? []&lt;/code&gt; is a common idiom for "a safe default when there's no value," but combined with a client like &lt;code&gt;supabase-js&lt;/code&gt; that represents failure with the same shape as success, it erases the distinction between "zero rows matched" and "the query itself failed." Here the trigger was a different bug — raw interpolation into &lt;code&gt;.or()&lt;/code&gt; — but the underlying danger is shared by every call site that reads &lt;code&gt;data&lt;/code&gt; without checking &lt;code&gt;error&lt;/code&gt;. In a design that bundles several categories into one request via &lt;code&gt;Promise.all&lt;/code&gt;, the category that fails is the one that goes quietly missing, and the fact that every other category returns normally is exactly what makes the gap hard to notice.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tech.rebounder.jp/en/posts/supabase-or-filter-comma-breaks-query-silently/" rel="noopener noreferrer"&gt;tech.rebounder.jp&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>supabase-js try/catch Doesn't Catch Errors: Check { error }</title>
      <dc:creator>cometa-kaito</dc:creator>
      <pubDate>Thu, 05 Nov 2026 02:18:47 +0000</pubDate>
      <link>https://dev.to/cometakaito/supabase-js-trycatch-doesnt-catch-errors-check-error--3f98</link>
      <guid>https://dev.to/cometakaito/supabase-js-trycatch-doesnt-catch-errors-check-error--3f98</guid>
      <description>&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;supabase-js query methods never throw on network or PostgREST failure.&lt;/strong&gt; The contract template's current-version resolver branched only on whether &lt;code&gt;data&lt;/code&gt; was present, without ever looking at the &lt;code&gt;error&lt;/code&gt; that came back alongside it. That meant a transient read failure couldn't be told apart from "no row has been published yet" — and when that return value fed a version-freeze decision, it could freeze an old version the customer never agreed to. The fix: pull the read into its own function, check &lt;code&gt;error&lt;/code&gt; explicitly, and fail closed (return &lt;code&gt;null&lt;/code&gt;, freeze nothing) when the read itself failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this can look like
&lt;/h2&gt;

&lt;p&gt;The affected code is kimiteras-portal's contract-template resolver (&lt;code&gt;contract-template-store.ts&lt;/code&gt;). The advertiser terms start as a JSON file shipped with the code (v1, the baseline, immutable). When an operator publishes a revision from the admin screen, a new version (v2 and up) is inserted into &lt;code&gt;contract_template_versions&lt;/code&gt; in the database. Two functions shared the "DB if present, baseline otherwise" logic: &lt;code&gt;getContractTemplate&lt;/code&gt;, which returns the body text, and &lt;code&gt;getContractMeta&lt;/code&gt;, which returns metadata such as the version number and whether an auto-renewal clause applies.&lt;/p&gt;

&lt;p&gt;Before the fix, &lt;code&gt;getContractMeta&lt;/code&gt; looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&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;getContractMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ContractMeta&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSupabaseAdminClient&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;data&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;admin&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contract_template_versions&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;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;kind&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is_current&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;maybeSingle&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;data&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;metaOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[contract-template] current meta read failed:&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;getContractMetaBaseline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kind&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;Only &lt;code&gt;data&lt;/code&gt; is destructured from &lt;code&gt;maybeSingle()&lt;/code&gt;'s return value — &lt;code&gt;error&lt;/code&gt; isn't even pulled out. When the read fails, &lt;code&gt;data&lt;/code&gt; simply comes back &lt;code&gt;null&lt;/code&gt;; nothing is thrown. So the code has no way to tell whether &lt;code&gt;data&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; because no version has been published yet, or because the read itself transiently failed — either case falls through to the same &lt;code&gt;return getContractMetaBaseline(kind)&lt;/code&gt; (the baseline v1 metadata).&lt;/p&gt;

&lt;p&gt;In the signing flow, &lt;code&gt;freezeSignedTermsSourceVersion&lt;/code&gt; sets &lt;code&gt;signed_terms_source_version&lt;/code&gt; from this return value, and &lt;code&gt;applyAutoRenewFromSignedTerms&lt;/code&gt; later decides whether to turn on &lt;code&gt;auto_renew&lt;/code&gt; based on that version's &lt;code&gt;has_auto_renew_clause&lt;/code&gt;. The baseline v1 can carry an auto-renewal clause while the current version an operator published (say, v3) doesn't. Every time the read transiently failed, v1 — not the version the customer actually read and agreed to — became the version frozen as the source of truth, opening a path to auto-renewal turning on with no contractual basis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause
&lt;/h2&gt;

&lt;p&gt;supabase-js query methods don't reject their promise. Network errors and PostgREST error responses are both expressed through the same &lt;code&gt;{data, error}&lt;/code&gt; return shape. If the caller looks only at &lt;code&gt;data&lt;/code&gt; and ignores &lt;code&gt;error&lt;/code&gt;, a failure is silently treated as just another case of "no data" — a normal-looking outcome. &lt;code&gt;try/catch&lt;/code&gt; is JavaScript's exception mechanism; it can't catch a failure that was never thrown. The mere presence of a &lt;code&gt;catch&lt;/code&gt; block gave a false sense that this function was handling failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The current-version read was pulled out into a dedicated function, &lt;code&gt;readCurrentRow&lt;/code&gt;, that explicitly checks &lt;code&gt;error&lt;/code&gt; and returns &lt;code&gt;{row, failed}&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readCurrentRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Row&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="nl"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSupabaseAdminClient&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;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&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;admin&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contract_template_versions&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;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;kind&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;version&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;ascending&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&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="nf"&gt;maybeSingle&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;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[contract-template] current read failed:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;row&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="na"&gt;failed&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;Row&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="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="na"&gt;failed&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="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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[contract-template] current read threw:&lt;/span&gt;&lt;span class="dl"&gt;"&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;row&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="na"&gt;failed&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getContractMeta&lt;/code&gt; now branches on that &lt;code&gt;failed&lt;/code&gt; flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&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;getContractMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ContractMeta&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readCurrentRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kind&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;row&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;metaOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getContractMetaBaseline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kind&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 read fails (&lt;code&gt;failed === true&lt;/code&gt;), it returns &lt;code&gt;null&lt;/code&gt; and does no version freeze at all — fail-closed. Since the caller, &lt;code&gt;freezeSignedTermsSourceVersion&lt;/code&gt;, already does nothing when &lt;code&gt;meta&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;, this change alone closes the path where a transient failure kept freezing the wrong version. Only once "no row exists (not yet published)" and "the read failed" were distinguishable did falling back to baseline actually mean what it was supposed to mean: &lt;em&gt;only&lt;/em&gt; when nothing had been published.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;getContractTemplate&lt;/code&gt;, which returns the body text, still falls back to baseline on a read failure — deliberately. "The body can't be shown" would stop the application and signing screens outright, a different trade-off that the team chose to keep, so it's intentionally asymmetric with the fail-closed &lt;code&gt;getContractMeta&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson
&lt;/h2&gt;

&lt;p&gt;This function already had a &lt;code&gt;try/catch&lt;/code&gt; at commit time, complete with a &lt;code&gt;console.error&lt;/code&gt; in the &lt;code&gt;catch&lt;/code&gt; block — whoever wrote it believed they were handling failure. But because supabase-js expresses failure through a return value rather than an exception, the moment only &lt;code&gt;data&lt;/code&gt; was destructured, &lt;code&gt;error&lt;/code&gt; was silently discarded and that &lt;code&gt;catch&lt;/code&gt; block became dead code that could never run. What made this a real defect rather than a minor oversight was that the function's return value fed directly into a version-freeze decision tied to billing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tech.rebounder.jp/en/posts/supabase-js-no-throw-contract-version-freeze-error-swallowed/" rel="noopener noreferrer"&gt;tech.rebounder.jp&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>pnpm-lock.yaml Made Vercel Misdetect an npm Project as pnpm</title>
      <dc:creator>cometa-kaito</dc:creator>
      <pubDate>Wed, 04 Nov 2026 02:20:09 +0000</pubDate>
      <link>https://dev.to/cometakaito/pnpm-lockyaml-made-vercel-misdetect-an-npm-project-as-pnpm-37li</link>
      <guid>https://dev.to/cometakaito/pnpm-lockyaml-made-vercel-misdetect-an-npm-project-as-pnpm-37li</guid>
      <description>&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The canonical package manager for this repo is npm (&lt;code&gt;package-lock.json&lt;/code&gt;), but a &lt;code&gt;pnpm-lock.yaml&lt;/code&gt; that had never once been committed to git was still sitting in the working directory, and it made Vercel misdetect the project as pnpm, breaking the build with a type error.&lt;/strong&gt; Once a &lt;code&gt;.vercelignore&lt;/code&gt; file exists, the Vercel CLI stops reading &lt;code&gt;.gitignore&lt;/code&gt;, so anything not explicitly listed in &lt;code&gt;.vercelignore&lt;/code&gt; gets uploaded — committed or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom
&lt;/h2&gt;

&lt;p&gt;Dependency management in this repo is npm, and &lt;code&gt;package.json&lt;/code&gt; pins &lt;code&gt;stripe&lt;/code&gt; to &lt;code&gt;^22.3.1&lt;/code&gt;.&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="nl"&gt;"stripe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^22.3.1"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;npm resolves that to &lt;code&gt;22.3.1&lt;/code&gt; and writes it into &lt;code&gt;package-lock.json&lt;/code&gt;.&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="nl"&gt;"node_modules/stripe"&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;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"22.3.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resolved"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://registry.npmjs.org/stripe/-/stripe-22.3.1.tgz"&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;p&gt;But the working directory also still had &lt;code&gt;pnpm-lock.yaml&lt;/code&gt; and &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt; sitting in it, both of which had never once been &lt;code&gt;git add&lt;/code&gt;ed. Pushing the build to Vercel failed with a type error caused by a mismatch in &lt;code&gt;stripe&lt;/code&gt;'s type definitions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;.vercelignore&lt;/code&gt; originally read like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.env*
/scripts
/supabase
/test-results
/README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pnpm-lock.yaml&lt;/code&gt; is not in that list. &lt;strong&gt;Once a &lt;code&gt;.vercelignore&lt;/code&gt; file exists, the Vercel CLI decides what to exclude from the upload using only what's written in &lt;code&gt;.vercelignore&lt;/code&gt;, and it does not consult &lt;code&gt;.gitignore&lt;/code&gt; at all.&lt;/strong&gt; So the usual assumption — "it's fine, I never pushed it to git" — does not hold here: the untracked &lt;code&gt;pnpm-lock.yaml&lt;/code&gt; / &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt; files sitting in the working directory were uploaded as-is.&lt;/p&gt;

&lt;p&gt;Vercel's build environment detects a project as pnpm based on the presence of &lt;code&gt;pnpm-lock.yaml&lt;/code&gt; among the uploaded files. Once that detection lands on pnpm, dependency resolution follows the pnpm lockfile too. In &lt;code&gt;pnpm-lock.yaml&lt;/code&gt;, &lt;code&gt;stripe&lt;/code&gt; had resolved to&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;stripe@22.6.0&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;which differs from the &lt;code&gt;22.3.1&lt;/code&gt; that the npm side had pinned. That gap surfaced directly as the type error that broke the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Two lines were added to &lt;code&gt;.vercelignore&lt;/code&gt;, explicitly excluding &lt;code&gt;pnpm-lock.yaml&lt;/code&gt; and &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt; from the upload.&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="s"&gt;/pnpm-lock.yaml&lt;/span&gt;
&lt;span class="s"&gt;/pnpm-workspace.yaml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The local files themselves were not deleted. They simply no longer get uploaded to Vercel — &lt;code&gt;.vercelignore&lt;/code&gt; remains the single place that decides what gets uploaded, so any untracked file you want excluded still has to be listed there going forward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preventing a repeat
&lt;/h2&gt;

&lt;p&gt;This article's sources don't include any record of a mechanical check being added to detect a stray pnpm lockfile. The fix here was also just adding the two files that were found to &lt;code&gt;.vercelignore&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What this exposes structurally is that &lt;strong&gt;"not pushed to git" and "not uploaded to Vercel" are two different claims.&lt;/strong&gt; The moment a &lt;code&gt;.vercelignore&lt;/code&gt; file is created at all, the basis for exclusion switches from &lt;code&gt;.gitignore&lt;/code&gt; to whatever &lt;code&gt;.vercelignore&lt;/code&gt; itself says. An untracked file sitting around locally causes no harm on its own — but in a repo that has a &lt;code&gt;.vercelignore&lt;/code&gt;, whether that file is the kind that conflicts with the canonical package manager needs to be checked the moment it appears, not later.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tech.rebounder.jp/en/posts/vercel-pnpm-lockfile-misdetect/" rel="noopener noreferrer"&gt;tech.rebounder.jp&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>tooling</category>
      <category>productivity</category>
      <category>programming</category>
    </item>
    <item>
      <title>DeepL and Whisper Quietly Save Time</title>
      <dc:creator>cometa-kaito</dc:creator>
      <pubDate>Mon, 02 Nov 2026 02:28:37 +0000</pubDate>
      <link>https://dev.to/cometakaito/deepl-and-whisper-quietly-save-time-6kk</link>
      <guid>https://dev.to/cometakaito/deepl-and-whisper-quietly-save-time-6kk</guid>
      <description>&lt;h2&gt;
  
  
  DeepL — making English read naturally
&lt;/h2&gt;

&lt;p&gt;We use it for reports and replying to email.&lt;/p&gt;

&lt;p&gt;The best part is simply that &lt;strong&gt;the English reads naturally&lt;/strong&gt;. There is little of the stiffness that gives machine translation away, and the output is usable as-is.&lt;/p&gt;

&lt;p&gt;What actually helped was less the translation quality than &lt;strong&gt;the change in order&lt;/strong&gt;. Even when writing English feels like a chore, switching to "draft in Japanese, then polish" lowers the barrier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Whisper — transcription that runs locally
&lt;/h2&gt;

&lt;p&gt;We used it when building an app that handles speech recognition.&lt;/p&gt;

&lt;p&gt;We picked it because &lt;strong&gt;it runs locally and is accurate&lt;/strong&gt;. &lt;strong&gt;Everything stays on the machine&lt;/strong&gt;, so no audio goes to the cloud. That is strong both for privacy and for offline operation.&lt;/p&gt;

&lt;p&gt;It became the first candidate whenever the requirement is "build transcription into my own app".&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing between them
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Writing and polishing English&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;DeepL&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turning speech into text, embedded in an app&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Whisper&lt;/strong&gt; (local)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  "Where it runs" as a selection criterion
&lt;/h2&gt;

&lt;p&gt;Putting the two side by side shows that &lt;strong&gt;accuracy is not the only axis in a selection.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Accuracy was part of why we chose Whisper, but what really mattered was &lt;strong&gt;where it runs&lt;/strong&gt;. "Audio never goes to the cloud" is not something you can add later the way accuracy can be improved. It is an &lt;strong&gt;architectural constraint&lt;/strong&gt;, and it has to be decided first.&lt;/p&gt;

&lt;p&gt;Rather than spending time comparing headline features, deciding "where does it run" and "what leaves the machine" first narrows the options on its own.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tech.rebounder.jp/en/posts/deepl-whisper/" rel="noopener noreferrer"&gt;tech.rebounder.jp&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>automation</category>
      <category>programming</category>
    </item>
    <item>
      <title>email_exists on createUser Hides a Silent Signup Failure</title>
      <dc:creator>cometa-kaito</dc:creator>
      <pubDate>Fri, 30 Oct 2026 02:19:51 +0000</pubDate>
      <link>https://dev.to/cometakaito/emailexists-on-createuser-hides-a-silent-signup-failure-5230</link>
      <guid>https://dev.to/cometakaito/emailexists-on-createuser-hides-a-silent-signup-failure-5230</guid>
      <description>&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When Supabase Auth's &lt;code&gt;auth.admin.createUser&lt;/code&gt; fails with a duplicate email (&lt;code&gt;email_exists&lt;/code&gt;), the code never distinguishes whether the cause is "someone else's existing account" or "a collision with the caller's own prior attempt."&lt;/strong&gt; In the process that auto-provisions a customer's submission account on every contract signature (&lt;code&gt;provisionCustomerAccount&lt;/code&gt;), this failure — until an E2E test caught it on 2026-07-22 — &lt;strong&gt;ended at an internal Slack warning only, with nothing changing on the customer's screen or in their inbox.&lt;/strong&gt; The scope of the 2026-07-23 fix is narrow: it does not change the email-duplicate detection itself, or the logic for looking up and reusing an existing Auth user. All it changes is letting the caller opt in to notifying the customer, and that option is only turned on for &lt;strong&gt;the automatic rail triggered by the contract-signing webhook.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Once a contract is signed, a customer submission account is auto-provisioned in Supabase Auth (&lt;code&gt;provisionCustomerAccount&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;If a Supabase Auth user already exists for the email being used, &lt;code&gt;createUser&lt;/code&gt; fails with &lt;code&gt;email_exists&lt;/code&gt;, and the function returns &lt;code&gt;reason: "auth_exists"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Previously, this branch only sent an internal warning via &lt;code&gt;notifySlack&lt;/code&gt; — no email ever reached the customer, and nothing changed on screen&lt;/li&gt;
&lt;li&gt;From the customer's side, they just waited for the submission-account instructions that should follow signing a contract, while in reality nothing was progressing&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cause
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;provisioning.ts&lt;/code&gt;'s idempotency check only looks at the &lt;code&gt;profiles&lt;/code&gt; table — it never checks whether the Auth-side user is still alive. The code's own comment states this design decision outright.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// provisioning.ts (e5fe39eaf5, lines 118-123)&lt;/span&gt;
&lt;span class="c1"&gt;// 1. Idempotency check: one account per company (§2.4 = company_id × role × is_active).&lt;/span&gt;
&lt;span class="c1"&gt;//    Counted per role (an advertiser's "customer" role and an organization's&lt;/span&gt;
&lt;span class="c1"&gt;//    "group_manager" role are separate accounts). A stale, disabled (is_active=false)&lt;/span&gt;
&lt;span class="c1"&gt;//    account will trigger a "try to re-provision" path, but if an Auth user with the&lt;/span&gt;
&lt;span class="c1"&gt;//    same email is still alive, it later fails with email_exists → auth_exists,&lt;/span&gt;
&lt;span class="c1"&gt;//    falling through to a Slack warning + manual handling (never issue duplicates / §2.6).&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In other words, this function was &lt;strong&gt;deliberately&lt;/strong&gt; built to attempt re-provisioning whenever &lt;code&gt;profiles&lt;/code&gt; has no active row. But if an Auth user with the same email is still present — whether that's a leftover from the caller's own earlier attempt, or an unrelated person's existing account — &lt;code&gt;createUser&lt;/code&gt; fails with the same &lt;code&gt;email_exists&lt;/code&gt;, and the function returns the same &lt;code&gt;auth_exists&lt;/code&gt; either way, without distinguishing the two.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// provisioning.ts (e5fe39eaf5, lines 148-178)&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;createErr&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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="nf"&gt;isEmailExistsError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;createErr&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// B-1 (actually observed via E2E on 2026-07-22): if an Auth user with the same&lt;/span&gt;
    &lt;span class="c1"&gt;// email already exists, provisioning fails, and previously this ended at a&lt;/span&gt;
    &lt;span class="c1"&gt;// Slack warning only — a silent hole where the customer was told nothing.&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notifyCustomerOnConflict&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="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;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auth_exists&lt;/span&gt;&lt;span class="dl"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;customerNotified&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;sendEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="cm"&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="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;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auth_exists&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customerNotified&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="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;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;createErr&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As the code comment states, at the point the E2E test found this, the &lt;code&gt;notifyCustomerOnConflict&lt;/code&gt; branch did not exist at all — &lt;code&gt;auth_exists&lt;/code&gt; always ended at a Slack warning and nothing else. There is also a rollback path that deletes the Auth user if the subsequent insert into &lt;code&gt;profiles&lt;/code&gt; fails,&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// provisioning.ts (e5fe39eaf5, lines 184-196)&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;profErr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&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="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&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;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profErr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;but the return value of that &lt;code&gt;deleteUser&lt;/code&gt; call is never checked. If the deletion itself fails, an "orphaned" Auth user with no corresponding &lt;code&gt;profiles&lt;/code&gt; row is left behind, and every subsequent provisioning attempt will from then on hit the same &lt;code&gt;auth_exists&lt;/code&gt; branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;On 2026-07-23, a &lt;code&gt;notifyCustomerOnConflict&lt;/code&gt; argument was added to &lt;code&gt;ProvisionInput&lt;/code&gt;, letting the caller choose whether the customer gets emailed when &lt;code&gt;auth_exists&lt;/code&gt; occurs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// provisioning.ts (e5fe39eaf5, lines 38-49)&lt;/span&gt;
&lt;span class="cm"&gt;/**
 * Whether to send the customer a notice email when auth_exists occurs (an Auth user
 * with the same email already exists) (B-1). Only true on the automated rail
 * (the contract-signing webhook). Manual provisioning (/admin/users, or allocating
 * to an organization) does not send it, since staff see the result on screen and
 * can act on it directly (this also avoids duplicate sends on every retry).
 */&lt;/span&gt;
&lt;span class="nx"&gt;notifyCustomerOnConflict&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The contract-signing webhook (&lt;code&gt;contract-signed.ts&lt;/code&gt;) calls this with &lt;code&gt;true&lt;/code&gt;, so the customer now also gets an email in addition to the Slack warning.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// contract-signed.ts (f1705230, lines 99-114)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&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;provisionCustomerAccount&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;companyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// On the automated rail, don't leave the customer silent even on auth_exists&lt;/span&gt;
  &lt;span class="c1"&gt;// (B-1 — send the notice email).&lt;/span&gt;
  &lt;span class="na"&gt;notifyCustomerOnConflict&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;// ...&lt;/span&gt;
&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auth_exists&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`⚠️ Submission account could not be provisioned: email &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escapeSlack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; conflicts with an existing user.`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`Customer notice email was &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customerNotified&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sent&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;sent⚠ (failed)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`Please provision manually with a different email address from &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;usersLink&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;link&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The manual provisioning path (&lt;code&gt;/admin/users&lt;/code&gt;, or allocating to an organization) does not pass this flag, so it stays at its default of &lt;code&gt;false&lt;/code&gt;. Since staff on the manual path see the result on screen in real time and can act on it, sending a customer email here too would mean every retry by staff sends the customer a duplicate notice. That's the reason the scope of the fix is described narrowly as "limited to one path."&lt;/p&gt;

&lt;p&gt;What actually happens when &lt;code&gt;auth_exists&lt;/code&gt; occurs hasn't changed either. The Slack message still only suggests "please provision manually with a different email address" — there is still no path that looks up and reuses the conflicting existing Auth user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generalizing further
&lt;/h2&gt;

&lt;p&gt;The cost of "figuring out whose existing record this actually is, when an existence check fails" gets deferred in most implementations. As this function's own comment shows, the design here &lt;strong&gt;deliberately&lt;/strong&gt; decided: "retry provisioning if &lt;code&gt;profiles&lt;/code&gt; has no active row, but fall through to a Slack warning plus manual handling if the Auth side is still alive." That's the right call to avoid issuing duplicates or wrongly linking an account to the wrong company or staff member — but the cost of that call is that the code never investigates whether a conflict traces back to the same person's past failure or to an unrelated third party.&lt;/p&gt;

&lt;p&gt;The reason the current fix stops at "tell the customer something" without going as far as "identify the cause of the conflict" is that the underlying design decision itself was never revisited. For a customer who hits &lt;code&gt;auth_exists&lt;/code&gt; on the same email, the system can only suggest "try your existing login, or get a new account issued under a different email" — either way, the system has no way to answer what that person actually did here in the past. Before deciding how far up the chain to notify about an existence-check failure, it's worth first checking whether the system is even built to tell what that failure actually means.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tech.rebounder.jp/en/posts/auth-user-exists-partial-signup-failure-idempotent-recovery/" rel="noopener noreferrer"&gt;tech.rebounder.jp&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>backend</category>
    </item>
    <item>
      <title>Three Errors You Actually Hit Connecting Actions to GCP</title>
      <dc:creator>cometa-kaito</dc:creator>
      <pubDate>Thu, 29 Oct 2026 02:20:40 +0000</pubDate>
      <link>https://dev.to/cometakaito/three-errors-you-actually-hit-connecting-actions-to-gcp-1h2</link>
      <guid>https://dev.to/cometakaito/three-errors-you-actually-hit-connecting-actions-to-gcp-1h2</guid>
      <description>&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Workload Identity Federation failures come from the configuration living in three layers. The error text does not tell you which one.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions side&lt;/strong&gt; — does the job have &lt;code&gt;id-token: write&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pool side&lt;/strong&gt; — does &lt;code&gt;attribute_condition&lt;/code&gt; permit the calling repository?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service account side&lt;/strong&gt; — is the &lt;code&gt;roles/iam.workloadIdentityUser&lt;/code&gt; binding still there?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any of the three missing produces similar text. Without deciding the order of investigation up front you end up poking at permissions at random. Below are the three errors we actually hit, in the order you hit them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;Unable to acquire impersonation credentials&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The first one you meet. The authentication step itself does not pass. There are three candidate causes, and &lt;strong&gt;the higher the layer, the cheaper it is to check.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The job has no &lt;code&gt;permissions&lt;/code&gt; with &lt;code&gt;id-token: write&lt;/code&gt;.&lt;/strong&gt; Entirely on GitHub's side, so look here first&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You are running from a different repository.&lt;/strong&gt; The pool's &lt;code&gt;attribute_condition&lt;/code&gt; limits the caller via &lt;code&gt;assertion.repository&lt;/code&gt;, so forks and other repositories are rejected&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;roles/iam.workloadIdentityUser&lt;/code&gt; binding on the service account was lost to drift.&lt;/strong&gt; Restore with &lt;code&gt;terraform apply&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The third is the awkward one. &lt;strong&gt;The code is correct and it still does not work&lt;/strong&gt;, and it happens unrelated to any recent change. Someone touching IAM by hand puts Terraform's state and the cloud out of sync, and it surfaces in this shape.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;Permission 'iam.serviceAccounts.getAccessToken' denied&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Here impersonation got as far as the entrance and &lt;strong&gt;the target service account refused.&lt;/strong&gt; It looks like a missing grant; in practice it is usually the wrong target.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The service account email is wrong.&lt;/strong&gt; Where plan and deploy accounts are separate, their permission scopes are separate too. Calling the deploy target with plan credentials is refused, correctly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The environment name does not match.&lt;/strong&gt; If Terraform's &lt;code&gt;env_name&lt;/code&gt; and the GitHub Environment the workflow references diverge, you end up pointed at the wrong service account&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The error only says "no permission".&lt;/strong&gt; It does not say "wrong target", so start by confirming the email is the one you meant.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Provider or pool not found
&lt;/h3&gt;

&lt;p&gt;The pool or provider simply does not exist. Check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud iam workload-identity-pools providers describe &amp;lt;provider-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;global &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--workload-identity-pool&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;pool-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;project-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it is absent, &lt;code&gt;terraform apply&lt;/code&gt; has not been run. Apply in that environment's directory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause
&lt;/h2&gt;

&lt;p&gt;All three share a root: &lt;strong&gt;the chain of trust is stored in three separate places.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;GitHub issues an OIDC token per job. The pool receives it and decides, through &lt;code&gt;attribute_condition&lt;/code&gt;, which repositories it accepts. What passes becomes a principal, and if that principal appears in the service account's IAM policy, impersonation succeeds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Those three are managed in different places&lt;/strong&gt;: the workflow YAML, the Terraform pool definition, and the service account IAM policy. Break any one and the chain breaks, but the break is only ever observable as an authentication error.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Reading the service account IAM policy directly is the fastest way to see the real state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud iam service-accounts get-iam-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &amp;lt;sa-name&amp;gt;@&amp;lt;project-id&amp;gt;.iam.gserviceaccount.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;project-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The expected state has a principalSet of this shape in &lt;code&gt;members&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;principalSet://iam.googleapis.com/projects/&amp;lt;num&amp;gt;/locations/global/workloadIdentityPools/&amp;lt;pool-id&amp;gt;/attribute.repository/&amp;lt;owner&amp;gt;/&amp;lt;repo&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;If the calling repository is not there, no amount of fixing the workflow will help.&lt;/strong&gt; If it is there, the lower two layers are alive and you go and look at &lt;code&gt;permissions&lt;/code&gt; on the GitHub side.&lt;/p&gt;

&lt;h2&gt;
  
  
  You can cut it off immediately
&lt;/h2&gt;

&lt;p&gt;Because there is no JSON key in the setup, &lt;strong&gt;there is nothing to rotate.&lt;/strong&gt; Instead you can stop impersonation from a repository directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud iam service-accounts remove-iam-policy-binding &lt;span class="se"&gt;\&lt;/span&gt;
  &amp;lt;sa-name&amp;gt;@&amp;lt;project-id&amp;gt;.iam.gserviceaccount.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;roles/iam.workloadIdentityUser &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--member&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'principalSet://iam.googleapis.com/projects/&amp;lt;num&amp;gt;/locations/global/workloadIdentityPools/&amp;lt;pool-id&amp;gt;/attribute.repository/&amp;lt;owner&amp;gt;/&amp;lt;repo&amp;gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The moment the binding is removed, authentication from that repository stops. There is no key expiry to wait for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But always update Terraform and open a PR afterwards.&lt;/strong&gt; Leaving a hand-edited IAM policy drifts the configuration, and the binding you removed reappears the next time someone applies. Pair the stopgap with the code change.&lt;/p&gt;

&lt;p&gt;And that drift, surfacing much later, is the first error in this article. &lt;strong&gt;A neglected emergency cut-off and an unexplained authentication failure are the same thing from two sides.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it less likely to recur
&lt;/h2&gt;

&lt;p&gt;The three layers are not going away. What you can change is &lt;strong&gt;fixing the order in which you check them.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cheapest first: &lt;code&gt;permissions&lt;/code&gt; on the GitHub side, then &lt;code&gt;get-iam-policy&lt;/code&gt; on the service account, then confirming the pool and provider exist. Following that order, the first is just opening a YAML file and the second is one command — enough to narrow down which of the three layers is broken.&lt;/p&gt;

&lt;p&gt;For another case where the error text does not lead to the cause, see &lt;a href="https://tech.rebounder.jp/en/posts/terraform-enabled-false-cannot-destroy/" rel="noopener noreferrer"&gt;terraform's enabled = false cannot tear down an environment&lt;/a&gt;, where the error appears in a module other than the one at fault.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tech.rebounder.jp/en/posts/gcp-workload-identity-errors/" rel="noopener noreferrer"&gt;tech.rebounder.jp&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>dpm set-device-owner Launches the App Before prefs Is Set</title>
      <dc:creator>cometa-kaito</dc:creator>
      <pubDate>Wed, 28 Oct 2026 02:25:39 +0000</pubDate>
      <link>https://dev.to/cometakaito/dpm-set-device-owner-launches-the-app-before-prefs-is-set-2c9p</link>
      <guid>https://dev.to/cometakaito/dpm-set-device-owner-launches-the-app-before-prefs-is-set-2c9p</guid>
      <description>&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;dpm set-device-owner&lt;/code&gt; is not a command that merely grants a privilege. The instant it succeeds, it launches the target app.&lt;/strong&gt; If that launch happens before the app's config file (prefs) holds the real &lt;code&gt;device_id&lt;/code&gt;, the app sees nothing but an empty config and &lt;strong&gt;mints and saves a new id for itself.&lt;/strong&gt; Worse, once the app is device owner, the usual fix — &lt;code&gt;force-stop&lt;/code&gt; — no longer reliably kills it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom
&lt;/h2&gt;

&lt;p&gt;The provisioning script that turns a factory-reset Google TV into a kiosk unit ran in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install the kiosk app&lt;/li&gt;
&lt;li&gt;Grant device ownership with &lt;code&gt;dpm set-device-owner&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;am force-stop&lt;/code&gt; the app&lt;/li&gt;
&lt;li&gt;Write a prefs file containing the correct &lt;code&gt;device_id&lt;/code&gt;, delivery URL, and so on&lt;/li&gt;
&lt;li&gt;Read prefs back with &lt;code&gt;run-as&lt;/code&gt; right after writing, to confirm the value landed&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 5's read-back always succeeded — reading a value right after writing it is supposed to show what you just wrote. But testing against real hardware on 2026-10-03 found that &lt;strong&gt;checking again a little while after provisioning finished&lt;/strong&gt;, some devices reported a &lt;code&gt;device_id&lt;/code&gt; different from the one that had been set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause
&lt;/h2&gt;

&lt;p&gt;As a matter of Android's own behavior, &lt;code&gt;dpm set-device-owner&lt;/code&gt; launches the app on the device once ownership is granted. Step 2 in the script did exactly that — &lt;strong&gt;before&lt;/strong&gt; step 4 ever wrote the correct prefs.&lt;/p&gt;

&lt;p&gt;At that point prefs is still empty (or holds a previous device's leftover value), so the app takes the same branch it would on any fresh first launch: "no &lt;code&gt;device_id&lt;/code&gt;? generate one and save it." That self-generated id ends up in memory, and the process stays running.&lt;/p&gt;

&lt;p&gt;Step 3's &lt;code&gt;am force-stop&lt;/code&gt; existed precisely to kill this "already running with a self-generated id" process. But &lt;strong&gt;once an app is device owner, force-stop no longer fully terminates it.&lt;/strong&gt; Even though step 4 overwrites the file on disk with the correct value, the still-living process keeps using the id it already has in memory, and depending on timing can write that id back over the file again. The result was a bug whose outcome depended on when you checked: step 5's immediate read-back showed the right value, while checking again moments later showed something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix
&lt;/h2&gt;

&lt;p&gt;The fix wasn't new code — it was &lt;strong&gt;reordering the steps.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; echo "== install APK"
 a install -r "$APK"
&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="gd"&gt;-echo "== Device Owner"
-a shell dpm set-device-owner "$PKG/.TvDeviceAdminReceiver"
-a shell dpm list-owners || true
-
&lt;/span&gt; echo "== config (prefs)"
 (... build an XML with device_id, delivery URL, etc ...)
 a shell am force-stop $PKG
 a shell "run-as $PKG sh -c '... write to shared_prefs/tv_ble_bridge.xml ...'"
 a shell "run-as $PKG cat shared_prefs/tv_ble_bridge.xml" | grep -E "device_id|device_label"
&lt;span class="gi"&gt;+# force-stop no longer works once device-owner is set; to rewrite, write prefs then adb reboot
+
+# device-owner has to come after prefs is written (it launches the app, and with
+# empty prefs the app mints and overwrites its own device_id)
+echo "== Device Owner"
+a shell dpm set-device-owner "$PKG/.TvDeviceAdminReceiver"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Moving &lt;code&gt;dpm set-device-owner&lt;/code&gt; to &lt;strong&gt;after&lt;/strong&gt; the prefs write means that by the moment device-owner provisioning launches the app, prefs already holds the real &lt;code&gt;device_id&lt;/code&gt;. The app never takes the branch that generates its own; it just reads the value that's already there.&lt;/p&gt;

&lt;p&gt;A delayed verification step was also added at the end of the script.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;got&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;a shell &lt;span class="s2"&gt;"run-as &lt;/span&gt;&lt;span class="nv"&gt;$PKG&lt;/span&gt;&lt;span class="s2"&gt; cat shared_prefs/tv_ble_bridge.xml"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEV&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$got&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"device_id OK"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"!! device_id was overwritten -&amp;gt; rewrite prefs and adb reboot"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An immediate read-back couldn't catch the "overwritten later" failure, so the script now re-checks &lt;code&gt;device_id&lt;/code&gt; once more &lt;strong&gt;after&lt;/strong&gt; the launch sequence (the part that brings the app to the foreground with &lt;code&gt;am start&lt;/code&gt;) has fully run. If it's wrong at that point, &lt;code&gt;adb reboot&lt;/code&gt; restarts the device. Force-stop can't kill a device-owner process, but a reboot guarantees it, and the device comes back up reading the correct prefs from disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape of this
&lt;/h2&gt;

&lt;p&gt;At its core, this bug is the combination of "an operation that grants a privilege immediately triggers a side effect that uses it" (here, the app auto-launching) with "the precondition that side effect depends on wasn't in place yet" (here, the config file's contents).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For operations where &lt;strong&gt;the moment you grant something, code runs&lt;/strong&gt; — privilege escalation, device-owner provisioning, install-time hooks like &lt;code&gt;BOOT_COMPLETED&lt;/code&gt; or a receiver's first launch — confirm config files and secrets are already in place beforehand.&lt;/li&gt;
&lt;li&gt;After escalation, &lt;strong&gt;the usual remedies that worked before escalation (force-stop, writing with ordinary permissions) can stop working with the exact same command.&lt;/strong&gt; "It worked a moment ago, so it should still work" is a premise worth re-checking once privilege has changed.&lt;/li&gt;
&lt;li&gt;An immediate read-back right after writing can't see a failure that only shows up later. Adding a &lt;strong&gt;second check after some time has passed&lt;/strong&gt; is what actually caught this timing-dependent bug.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tech.rebounder.jp/en/posts/dpm-set-device-owner-launches-app-before-prefs-written/" rel="noopener noreferrer"&gt;tech.rebounder.jp&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>iot</category>
      <category>kotlin</category>
      <category>programming</category>
    </item>
    <item>
      <title>enabled: false Silently Disabled Claude Code Scheduled Tasks</title>
      <dc:creator>cometa-kaito</dc:creator>
      <pubDate>Tue, 27 Oct 2026 03:59:11 +0000</pubDate>
      <link>https://dev.to/cometakaito/enabled-false-silently-disabled-claude-code-scheduled-tasks-3ncl</link>
      <guid>https://dev.to/cometakaito/enabled-false-silently-disabled-claude-code-scheduled-tasks-3ncl</guid>
      <description>&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Running Claude Code's scheduled tasks unattended, we found they stop &lt;strong&gt;three different ways&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;How it stops&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;Fix needed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;① Never starts&lt;/td&gt;
&lt;td&gt;Mac was asleep, or the app was closed&lt;/td&gt;
&lt;td&gt;None — it runs automatically on the next launch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;② Starts, then dies mid-run&lt;/td&gt;
&lt;td&gt;A dropped connection, so the API was never reached&lt;/td&gt;
&lt;td&gt;No retry exists. &lt;strong&gt;The next slot has to catch up&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;③ The slot itself gets disabled&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;enabled: false&lt;/code&gt;, with no one intending it&lt;/td&gt;
&lt;td&gt;Check the list daily and re-enable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of the three show up in the log of the run that actually happened. That's why we judge by "did the artifact land in git" instead of "did it run."&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually running
&lt;/h2&gt;

&lt;p&gt;This blog's (tech.rebounder.jp) articles are written by scheduled tasks in Claude Code's desktop app. There are 6 slots a day: 4 for writing articles (08:06 / 12:08 / 16:05 / 20:07) and 2 for stocking angles and pulling traffic numbers (11:02 / 17:07). We've run this setup for 7 weeks starting 2026-08-16.&lt;/p&gt;

&lt;p&gt;Every run happens with &lt;strong&gt;no screen&lt;/strong&gt;, and no human is in the loop. So we can't assume "someone will notice" when something stops.&lt;/p&gt;

&lt;h2&gt;
  
  
  ① Never starts — this one was fine to leave alone
&lt;/h2&gt;

&lt;p&gt;If the Mac is asleep or the app is closed, nothing happens at that time. But this &lt;strong&gt;ran automatically the next time the app launched&lt;/strong&gt;. In one measurement, the 8/17 20:07 slot completed on 8/18 at 09:59.&lt;/p&gt;

&lt;p&gt;This isn't the one that needs attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  ② Starts, then dies mid-run — there is no retry
&lt;/h2&gt;

&lt;p&gt;This is the one that hurts. The 2026-08-17 20:02 night slot died 4 minutes after starting, with this error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;API Error: Can't reach the API server (ENOTFOUND)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The log was 12 lines, and not a single file had been touched. &lt;strong&gt;It died, so it couldn't record anything itself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two days later, on 8/19, the connection dropped for about an hour, and the 11:02 and 12:08 slots died back to back. &lt;strong&gt;There was no retry, and that slot was lost for good.&lt;/strong&gt; Because the run itself dies before reaching the API, writing "retry on failure" into the runbook doesn't help either — there's no process left to read it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Let the next slot catch up
&lt;/h3&gt;

&lt;p&gt;The only lever we had was "the next slot catches up." Now, at the start of every slot, we count how many articles have gone out today and how many slots have already passed, and &lt;strong&gt;write the shortfall in that slot&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We capped the catch-up at &lt;strong&gt;2 articles per run&lt;/strong&gt;, for three reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Publishing several articles on the same topic in one day collides more easily&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The longer one run takes, the higher the chance it dies mid-run&lt;/strong&gt; — trying to catch up just creates more of failure mode ②&lt;/li&gt;
&lt;li&gt;It burns through the stock of material faster&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Do the parts you can't recover first
&lt;/h3&gt;

&lt;p&gt;We also changed the order. Traffic numbers only last &lt;strong&gt;7 days&lt;/strong&gt; on the Cloudflare side, so a day you miss is gone forever. An article, on the other hand, can always be carried over to the next day. So after the 8/19 miss, we made every article-writing slot &lt;strong&gt;pull the traffic numbers first, before touching the article.&lt;/strong&gt; If even one of the 4 slots runs, that day's data is captured.&lt;/p&gt;

&lt;h3&gt;
  
  
  "It ran" and "it did the work" are not the same thing
&lt;/h3&gt;

&lt;p&gt;The 8/18 10:01 stocking slot has an execution record (a last-run timestamp) showing it ran, but it neither stocked angles nor pulled traffic — it just wrote one file and stopped. It had overlapped with a carried-over run (which finished at 10:02).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you only look at the execution record, this looks like a success.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  ③ The slot itself gets disabled — missed for a week
&lt;/h2&gt;

&lt;p&gt;This is the one we missed longest. On 2026-09-27–28, 3 of the 4 article-writing slots had been set to &lt;code&gt;enabled: false&lt;/code&gt;. &lt;strong&gt;We didn't notice for a week.&lt;/strong&gt; Daily output dropped from 1–4 articles a day to &lt;strong&gt;exactly 1 a day.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The 5 runs right before that had all succeeded — this wasn't caused by a failure. No one remembered disabling it, either. &lt;strong&gt;No one intended to stop it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Our monitoring dashboard did render this as a "failure." But it &lt;strong&gt;looked identical to a run that died from a dropped connection&lt;/strong&gt;, so it got lost among 16 other entries. The root problem was that we weren't distinguishing "one run failed" from "the slot itself is disabled."&lt;/p&gt;

&lt;p&gt;Now, at the start of the first slot each day, we list every scheduled task. If a writing slot is set to &lt;code&gt;false&lt;/code&gt;, we flip it back to &lt;code&gt;true&lt;/code&gt; and add one line to the report: "X was disabled, so I re-enabled it." If we ever want a slot intentionally stopped, its name goes into the runbook as an exception. &lt;strong&gt;Any slot not named there gets flipped back on every day.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Judge by the artifact, not by self-report
&lt;/h2&gt;

&lt;p&gt;What all three have in common is that &lt;strong&gt;trusting the run's own record misses things.&lt;/strong&gt; A run that died can't record anything, and a run that merely executed will call itself a "success."&lt;/p&gt;

&lt;p&gt;So for every slot we decided which file should land in git if that slot actually did its job — an article file for a writing slot, measurement data for a stocking slot — and we judge by whether that file showed up. The morning and evening stocking slots write to the same kind of file, so they wouldn't be distinguishable from each other; we gave them &lt;strong&gt;separate heartbeat files&lt;/strong&gt; instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  We measured the wait time instead of guessing it
&lt;/h3&gt;

&lt;p&gt;We'd originally guessed 120 minutes as the time from "run starts" to "artifact appears." Here's what we actually measured from 8/17–8/30:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Slot&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;Max&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;08:06 morning article&lt;/td&gt;
&lt;td&gt;13 min&lt;/td&gt;
&lt;td&gt;113 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11:02 stocking&lt;/td&gt;
&lt;td&gt;107 min&lt;/td&gt;
&lt;td&gt;171 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12:08 midday article&lt;/td&gt;
&lt;td&gt;78 min&lt;/td&gt;
&lt;td&gt;125 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16:05 evening article&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;122 min&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;163 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;17:07 evening stocking&lt;/td&gt;
&lt;td&gt;68 min&lt;/td&gt;
&lt;td&gt;130 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20:07 night article&lt;/td&gt;
&lt;td&gt;19 min&lt;/td&gt;
&lt;td&gt;169 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The evening article slot's &lt;strong&gt;median exceeded the window itself.&lt;/strong&gt; Articles were going out, but more than half were showing up as "failed," reporting a 33% success rate. We widened the window to 180 minutes and the false alarms went away. &lt;strong&gt;A dashboard that cries wolf eventually gets ignored by everyone.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: an unattended run stuck waiting for permission
&lt;/h2&gt;

&lt;p&gt;Separate from the three ways of stopping, on day one (2026-08-16) we hit a permission check. Since no one can approve anything in an unattended run, the slot just ends the moment a confirmation prompt appears. There were two causes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chaining multiple commands with &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; or &lt;code&gt;;&lt;/code&gt; &lt;strong&gt;doesn't match any entry in the allowlist&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cd &amp;lt;dir&amp;gt; &amp;amp;&amp;amp; git ...&lt;/code&gt; triggers a confirmation &lt;strong&gt;every time&lt;/strong&gt;, because the target directory's git hooks could run. This can't be removed from the allowlist&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We avoided both by changing how we write commands: &lt;strong&gt;one command per call&lt;/strong&gt;, specifying the location with &lt;code&gt;git -C &amp;lt;dir&amp;gt;&lt;/code&gt; and &lt;code&gt;npm --prefix &amp;lt;dir&amp;gt;&lt;/code&gt; instead of &lt;code&gt;cd&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;If you're going to run Claude Code's scheduled tasks unattended, assume they will stop. A run that never started catches up on its own, but &lt;strong&gt;a run that died mid-run never comes back, and a disabled slot won't tell anyone.&lt;/strong&gt; We settled on judging by "did the artifact land," letting the next slot catch up to 2 articles, and pulling unrecoverable data first in every slot.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tech.rebounder.jp/en/posts/claude-code-scheduled-tasks-three-ways-to-stop/" rel="noopener noreferrer"&gt;tech.rebounder.jp&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>automation</category>
      <category>programming</category>
    </item>
    <item>
      <title>Only Our Vercel API Functions Threw Cannot Find Module</title>
      <dc:creator>cometa-kaito</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:24:52 +0000</pubDate>
      <link>https://dev.to/cometakaito/only-our-vercel-api-functions-threw-cannot-find-module-2pic</link>
      <guid>https://dev.to/cometakaito/only-our-vercel-api-functions-threw-cannot-find-module-2pic</guid>
      <description>&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Local type-checking and the build don't know that &lt;code&gt;api/&lt;/code&gt; runs unbundled in production.&lt;/strong&gt; &lt;code&gt;tsconfig.json&lt;/code&gt;'s &lt;code&gt;moduleResolution: "Bundler"&lt;/code&gt; type-checks an extensionless relative import (&lt;code&gt;from "./_lib"&lt;/code&gt;) as valid, and &lt;code&gt;vite build&lt;/code&gt; only bundles the frontend (&lt;code&gt;src/&lt;/code&gt;). Both pass — yet the &lt;code&gt;api/&lt;/code&gt; functions, which &lt;code&gt;@vercel/node&lt;/code&gt; deploys and runs individually, crashed in production with &lt;code&gt;Cannot find module&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix: add the post-build &lt;code&gt;.js&lt;/code&gt; extension to every relative import under &lt;code&gt;api/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom
&lt;/h2&gt;

&lt;p&gt;During an end-to-end check of Kyoshu (a free sponsor-funded polling/Q&amp;amp;A board, its first shipped version) in production, all five API Functions were down: &lt;code&gt;/api/ad&lt;/code&gt; (counts sponsor-card views/clicks), &lt;code&gt;/api/cleanup&lt;/code&gt; (closes a room), &lt;code&gt;/api/host&lt;/code&gt; (host actions), &lt;code&gt;/api/rooms&lt;/code&gt; (joining a room), and &lt;code&gt;/api/sponsor&lt;/code&gt; (sponsor display). The same code worked fine under local &lt;code&gt;vite dev&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;package.json&lt;/code&gt; has &lt;code&gt;"type": "module"&lt;/code&gt;, so Node.js interprets the code as ESM at runtime.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tsconfig.json&lt;/code&gt; sets &lt;code&gt;moduleResolution&lt;/code&gt; to &lt;code&gt;"Bundler"&lt;/code&gt;:&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;"compilerOptions"&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;"module"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ESNext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"moduleResolution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bundler"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&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;p&gt;This mode relaxes type-checking on the assumption that some bundler will resolve the import in the end, so an extensionless relative import (&lt;code&gt;from "./_lib"&lt;/code&gt;) type-checks as fine. For the frontend (&lt;code&gt;src/&lt;/code&gt;), that assumption holds — Vite really does bundle everything at build time.&lt;/p&gt;

&lt;p&gt;But &lt;code&gt;api/&lt;/code&gt; is deployed differently. Per &lt;code&gt;vercel.json&lt;/code&gt;, &lt;code&gt;@vercel/node&lt;/code&gt; deploys each file under &lt;code&gt;api/&lt;/code&gt; as its own serverless function:&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;"regions"&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="s2"&gt;"hnd1"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"crons"&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;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/cleanup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"schedule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0 18 * * *"&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="err"&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;p&gt;This path skips Vite's bundling. The TypeScript is transpiled close to 1:1, and the plain Node.js ESM loader resolves the relative import at runtime — and that loader does not allow omitting the extension. Line 3 of &lt;code&gt;ad.ts&lt;/code&gt; read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// before&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;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isRoomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;today&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="s2"&gt;./_lib&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;Neither &lt;code&gt;tsc --noEmit&lt;/code&gt; nor &lt;code&gt;vite build&lt;/code&gt; can catch this line. The former judges it type-valid under &lt;code&gt;moduleResolution: Bundler&lt;/code&gt;; the latter never looks at &lt;code&gt;api/&lt;/code&gt; in the first place. The build stays green every time — the failure only surfaces when &lt;code&gt;/api/*&lt;/code&gt; is actually hit in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix
&lt;/h2&gt;

&lt;p&gt;All five files with a relative import from &lt;code&gt;./_lib&lt;/code&gt; — &lt;code&gt;ad.ts&lt;/code&gt;, &lt;code&gt;cleanup.ts&lt;/code&gt;, &lt;code&gt;host.ts&lt;/code&gt;, &lt;code&gt;rooms.ts&lt;/code&gt;, &lt;code&gt;sponsor.ts&lt;/code&gt; — got the &lt;code&gt;.js&lt;/code&gt; extension added to the import:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// after (api/ad.ts)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isRoomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;today&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="s2"&gt;./_lib.js&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 source file stays &lt;code&gt;.ts&lt;/code&gt;. TypeScript allows writing the post-build extension (&lt;code&gt;.js&lt;/code&gt;) in a relative import under both &lt;code&gt;moduleResolution: Bundler&lt;/code&gt; and &lt;code&gt;NodeNext&lt;/code&gt;. Since what actually runs in production is the compiled &lt;code&gt;.js&lt;/code&gt;, that's the only extension the Node.js ESM loader can resolve &lt;code&gt;_lib&lt;/code&gt; with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it went unnoticed
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;moduleResolution: "Bundler"&lt;/code&gt; is written for the frontend build and local development (&lt;code&gt;vite dev&lt;/code&gt;) — both of which really do get bundled by Vite, so the extension never matters there.&lt;/p&gt;

&lt;p&gt;But &lt;code&gt;api/&lt;/code&gt; is deployed separately, one function at a time, by &lt;code&gt;@vercel/node&lt;/code&gt;, bypassing Vite's bundling entirely. &lt;code&gt;tsconfig.json&lt;/code&gt;'s &lt;code&gt;include&lt;/code&gt; does list &lt;code&gt;api&lt;/code&gt;, but &lt;code&gt;moduleResolution&lt;/code&gt;'s own behavior has no concept of "&lt;code&gt;api/&lt;/code&gt; alone runs on a different runtime." Both type-checking and the build stayed green; the mismatch only showed up as a production runtime error.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tech.rebounder.jp/en/posts/vercel-node-functions-relative-import-missing-js-extension/" rel="noopener noreferrer"&gt;tech.rebounder.jp&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>tooling</category>
      <category>productivity</category>
      <category>programming</category>
    </item>
    <item>
      <title>gcloud ADC Expires After ~44h: Reauthentication failed Fix</title>
      <dc:creator>cometa-kaito</dc:creator>
      <pubDate>Fri, 23 Oct 2026 08:50:49 +0000</pubDate>
      <link>https://dev.to/cometakaito/gcloud-adc-expires-after-44h-reauthentication-failed-fix-2a59</link>
      <guid>https://dev.to/cometakaito/gcloud-adc-expires-after-44h-reauthentication-failed-fix-2a59</guid>
      <description>&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Credentials created with &lt;code&gt;gcloud auth application-default login&lt;/code&gt; (ADC) could not keep a headless scheduled job running. &lt;strong&gt;They expired after about 44 hours, and every run stopped here:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Reauthentication failed. cannot prompt during non-interactive execution.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The account behind the login was a &lt;strong&gt;Google Workspace account&lt;/strong&gt; with a reauthentication policy. A scheduled job has no screen, so nobody can type the password again. &lt;strong&gt;Once it expires, it never works again.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix was to &lt;strong&gt;authenticate with a service account key&lt;/strong&gt; instead. A service account has no expiry and no reauthentication.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the job does
&lt;/h2&gt;

&lt;p&gt;This blog (tech.rebounder.jp) is written every day by an AI agent running with no screen. As part of that run, a step pulls impressions, clicks, and average position from the Search Console API and writes them to a file. It doesn't use a client library — it calls the API directly with Node.js &lt;code&gt;fetch&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On 2026-08-30 it first worked with local ADC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud auth application-default login
gcloud auth application-default print-access-token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put that token in &lt;code&gt;Authorization: Bearer&lt;/code&gt; and the API answers. Run by hand, nothing looked wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  It expired after about 44 hours
&lt;/h2&gt;

&lt;p&gt;It broke about 44 hours later, on 2026-09-01. The &lt;code&gt;Reauthentication failed. cannot prompt during non-interactive execution.&lt;/code&gt; error above appeared, and every run after that stopped at the same place.&lt;/p&gt;

&lt;p&gt;The cause is the account type. The login was a company Workspace account, &lt;strong&gt;and Workspace applies a reauthentication policy.&lt;/strong&gt; It periodically asks for the password again, and a scheduled job has no screen to answer with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automation that needs a human to log in again every couple of days isn't automation.&lt;/strong&gt; That's where ADC stopped being the main path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching to a service account
&lt;/h2&gt;

&lt;p&gt;Build a JWT from the service account key (JSON) and exchange it for an access token yourself. It only needs &lt;code&gt;node:crypto&lt;/code&gt; — no library.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sa&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SA_KEY&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;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&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="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;b64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&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;base64url&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;unsigned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;alg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RS256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;typ&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;JWT&lt;/span&gt;&lt;span class="dl"&gt;'&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="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;iss&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.googleapis.com/auth/webmasters.readonly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://oauth2.googleapis.com/token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;iat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})}&lt;/span&gt;&lt;span class="s2"&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;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RSA-SHA256&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;unsigned&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;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64url&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://oauth2.googleapis.com/token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;grant_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;urn:ietf:params:oauth:grant-type:jwt-bearer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;assertion&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;unsigned&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;sig&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three decisions went with it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The key lives &lt;strong&gt;outside the repository&lt;/strong&gt; (a config folder under the home directory) with &lt;code&gt;600&lt;/code&gt; permissions&lt;/li&gt;
&lt;li&gt;The service account has &lt;strong&gt;no GCP roles at all.&lt;/strong&gt; Its only permission is "can read this property," granted on the Search Console side. If the key leaks, all it can do is read this blog's search data&lt;/li&gt;
&lt;li&gt;Register the service account's email address under "Add user" in Search Console. &lt;strong&gt;Restricted permission is enough for reading&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ADC path is still in the code, but it isn't relied on, because Workspace reauthentication will break it.&lt;/p&gt;

&lt;h2&gt;
  
  
  403 comes back with three different causes
&lt;/h2&gt;

&lt;p&gt;The switch hit 403 several times. The hard part: &lt;strong&gt;"missing scope," "wrong account," and "wrong project" all come back as the same 403.&lt;/strong&gt; Until you can tell which one it is, you can't find the right fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  ADC needs a quota project header
&lt;/h3&gt;

&lt;p&gt;With personal-account ADC and a raw &lt;code&gt;fetch&lt;/code&gt;, the API returned &lt;code&gt;403 SERVICE_DISABLED&lt;/code&gt; even with the right scope (2026-08-30). The message reads like a permissions problem, so it's easy to misread as a scope issue.&lt;/p&gt;

&lt;p&gt;The real cause was the missing quota project (the project API usage is billed to). &lt;code&gt;gcloud auth application-default set-quota-project&lt;/code&gt; configures client libraries — &lt;strong&gt;it has no effect on a raw fetch.&lt;/strong&gt; You have to send it as a header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-goog-user-project&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;lt;project-id&amp;gt;&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;h3&gt;
  
  
  A service account must not send it
&lt;/h3&gt;

&lt;p&gt;Send that same header with a service account, though, and it causes a 403 of its own (2026-09-01). A service account &lt;strong&gt;implicitly bills to the project it belongs to.&lt;/strong&gt; Adding &lt;code&gt;x-goog-user-project&lt;/code&gt; on top means the service account itself needs the &lt;code&gt;serviceUsageConsumer&lt;/code&gt; role, which it doesn't have, so the call is rejected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The header is only for ADC.&lt;/strong&gt; The code sends it depending on which auth path produced the token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;quotaHeader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;saToken&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;x-goog-user-project&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;QUOTA_PROJECT&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Logged in as a different account
&lt;/h3&gt;

&lt;p&gt;Even after fixing the quota project, 403 kept coming. This time the login was &lt;strong&gt;a different account.&lt;/strong&gt; The property list didn't include the target site.&lt;/p&gt;

&lt;p&gt;And because the login had used &lt;code&gt;--scopes&lt;/code&gt;, the default &lt;code&gt;email&lt;/code&gt; scope was dropped, so &lt;strong&gt;there was no way to show who was logged in.&lt;/strong&gt; The recommended login command now adds &lt;code&gt;openid,email&lt;/code&gt; to the scopes, and the token's email address is printed first. Before calling the API, the job now checks that the target site is in the property list; if it isn't, it prints "which account, and what it can see" and stops.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small trap: gcloud won't start on Python 3.9
&lt;/h2&gt;

&lt;p&gt;This Mac's default Python is 3.9, and gcloud wouldn't start on it. When the ADC path runs &lt;code&gt;gcloud&lt;/code&gt; as a child process, it sets &lt;code&gt;CLOUDSDK_PYTHON&lt;/code&gt; to 3.10.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't write "0" on a day it failed
&lt;/h2&gt;

&lt;p&gt;One more change was how failure is handled. When the API can't be reached, &lt;strong&gt;the previous values are kept and only an &lt;code&gt;error&lt;/code&gt; is recorded.&lt;/strong&gt; Showing "0 impressions" on the day the API was down is the worst way for this to break.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;Reauthentication failed. cannot prompt during non-interactive execution&lt;/code&gt; shows up when ADC was created with an account that needs human reauthentication and is then used by a job with no screen. It should look the same from cron, CI, or an AI agent's scheduled run. &lt;strong&gt;For anything unattended, use a service account.&lt;/strong&gt; Then, if you get a 403, figure out first whether it's the scope, the account, or the project — it saves a long detour.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tech.rebounder.jp/en/posts/gcloud-adc-reauthentication-failed-non-interactive-service-account/" rel="noopener noreferrer"&gt;tech.rebounder.jp&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
