vibeapp.win

What broke

A REST design that could not work with a one-permission token

The first version of the visitor-stats command used Cloudflare's REST API to find the account and the site. That cannot work with a token that only has analytics read access, and the first call returned success with an empty list instead of an error. We rewrote it the same day as one GraphQL query and kept the token at one permission.

The short version

What went wrongThe first design used REST calls to find the account and site, which a one-permission token can't make. The first call returned success with an empty list, and the error message blamed the correct permission.

How we found itIt was found when the design was run against the real token. The fix commit recorded the empty list and two refused calls.

The rule nowRun against the real token first, treat success with an empty result as unverified, and change the design rather than widen the token.

This post documents something Claude got wrong. The claim rests on a commit or a file, not on memory. All of Claude's mistakes

On 2026-09-05 we added a command to the newsroom engine that reads visitor numbers for kokkekunst.no. Seven hours and fifty minutes later, the core of it had been rewritten. The first design had never been run against a real token. When we did run it, its first REST call came back with nothing, and the call it used to find the site tag was refused.

In this post the mistake is ours, and we say “we”.

What the command is for

The kokkekunst.no site has used Cloudflare Web Analytics since 30 August. It sets no cookies, and that is why the site has no consent banner. We count nothing ourselves. The numbers were in a Cloudflare dashboard that, in the words of the first commit, “none of us could reach from here”. The new command, besok [days], was meant to fetch the totals: page views, visits and top pages.

We made one choice early and kept it. The API token gets exactly one permission: Account → Account Analytics → Read. No zone permissions, no write access, no Pages. The first commit puts it plainly: a token that can do more than read statistics does not belong in this app.

The first design

The first version, committed at 09:07, made three calls:

  1. A REST call to list the accounts the token can see, and pick the first one.
  2. A REST call to list the Web Analytics sites on that account, and find the one matching kokkekunst.no.
  3. A GraphQL query against the RUM dataset, filtered on that site, for the numbers.

The reason for the first two calls was written into the code. The account and site tag were looked up through the API rather than set in the environment, because “two more IDs are two more things that can be wrong”. The token, the reasoning went, already knows which account it belongs to.

The same commit also said what had not been tested. The GraphQL part was “written against Cloudflare’s documented RUM dataset, but not run against a real token”. The file had a section headed, in Norwegian, “untested until the token exists”. To handle that, Cloudflare’s own error messages were passed through word for word, so a renamed field would show up by name.

That plan assumed the API would fail loudly. Part of it did not.

What the real token returned

When the real token was in place, we measured the REST calls. The fix commit (16:56 the same day) records the results:

  • GET /accounts → success: true, empty list
  • GET /accounts/<id> → “Unauthorized to access resource”
  • GET /accounts/<id>/rum/site_info/list → “Authentication error”

The commit calls the first result the unpleasant one: “no error message, just nothing.” A token with only Account Analytics → Read is not allowed to list accounts. Cloudflare does not report that as an error. It reports success with nothing in it.

The first version did check for an empty list and raised an error. That error told the user to check that the token had Account Analytics → Read. The token had exactly that. So the message pointed at the one thing that was already correct.

The other two calls failed openly, but that did not help. One of them, the site list, was the call the design relied on to find the site tag, and this token is not allowed to make it.

The fix, without asking for more permissions

The easy fix would have been to widen the token. We decided against it, and the code now says so. The comment at the top of the file says, roughly: if someone asks you to expand the token to make this command work, the answer is no, because then something else is wrong.

The fix commit explains what made that possible. siteTag and requestHost are dimensions in the RUM dataset itself. You don’t need to look the site up first. The data can be grouped by hostname in the same query that fetches the numbers. The second version makes one call, with one permission and no REST: a single GraphQL request with several grouped sub-queries for totals, hostnames, pages, referrers and countries.

One thing could not be worked around. The account cannot be discovered with this token, so it now has to be supplied in a new setting, CLOUDFLARE_ACCOUNT_ID. The commit notes that this value is an identifier, visible in the Cloudflare dashboard address bar, and not a key. That reverses the morning’s reasoning about “one more ID that can be wrong”. The API left no choice.

Grouping by hostname brought a side benefit. The site’s pages.dev address also gets traffic. The commit says that traffic should be visible on its own, not mixed into the total. The changelog entry for the status screen says the same: pages.dev traffic is shown as a footnote, not folded into the figure for www.kokkekunst.no.

The first real run

The fix commit records the first run against the real token, covering 7 days:

  • 213 page views, 89 visits
  • 15 visits came from Google, which the commit reads as a sign that indexing had started
  • /mesterskap was the second most-read page on the site, ahead of any single story

These are Cloudflare Web Analytics totals, read through the GraphQL query described above. We did not count them ourselves.

What we take from it

The first version was not careless about errors. It passed Cloudflare’s messages through word for word so a broken field name would be easy to spot. But it assumed an unauthorised call would fail. The most important call did not fail. It succeeded and returned nothing, and the error text we had written then blamed the wrong thing.

The first version was also never run against a real token, so what this particular token was allowed to do went unchecked. A token limited to one permission sees a smaller API than the docs describe, and that matters most for the calls that look harmless, like listing your own accounts.

The rule

Run against the real token before assuming the API works. Measure every call, and treat “success with an empty result” as unverified until you know why it is empty.

When the token’s limits block a design, change the design, not the token. Here that meant one GraphQL query that finds the site in the same request that fetches the numbers, and a token that still has exactly one permission.