Skip to content

fix(sync): re-mint the workspace token on Plaud status -419#38

Open
lealwtf wants to merge 2 commits into
rsteckler:mainfrom
lealwtf:fix/plaud-419-workspace-token-remint
Open

fix(sync): re-mint the workspace token on Plaud status -419#38
lealwtf wants to merge 2 commits into
rsteckler:mainfrom
lealwtf:fix/plaud-419-workspace-token-remint

Conversation

@lealwtf

@lealwtf lealwtf commented Jul 15, 2026

Copy link
Copy Markdown

Problem

Plaud signals a rejected workspace token in two different ways, and the poller only recovers from one of them.

plaudFetch maps HTTP 401 to PlaudAuthError, and Poller.runOnce uses that to force a one-shot WT re-mint and retry:

if (err instanceof PlaudAuthError && loadConfig().authMode === "first_party") {
  await ensureFreshWorkspaceToken(true);
  await this.pollAndProcess();
  return;
}

But the list endpoint also rejects the token in the response body, with HTTP 200. Reproduced directly against the API with an expired WT:

$ curl -s -o body.json -w '%{http_code}\n' \
    -H 'authorization: Bearer <expired-workspace-token>' \
    'https://api.plaud.ai/file/simple/web?skip=0&limit=1&is_trash=2&sort_by=start_time&is_desc=true'
200

$ cat body.json
{"status":-419,"msg":"workspace token expired"}

HTTP 200 never reaches the 401 branch, so pollAndProcess throws a plain Error here:

if (page.status !== 0) {
  throw new Error(`Plaud list returned status=${page.status} msg=${page.msg}`);
}

Since it isn't a PlaudAuthError, the force-re-mint recovery is skipped and handlePollError records it as a generic error.

Why it doesn't self-heal

ensureFreshWorkspaceToken() (no force) runs before every poll, but it early-returns while the cached WT still looks fresh:

const wtFresh = cfg.token != null && !isExpiryWithinSkew(cfg.tokenExp, WT_REFRESH_SKEW_MS);
if (wtFresh && !force) return;

The catch is that the WT's JWT exp claim and Plaud's actual acceptance of it are not the same thing. On my install a freshly minted WT carried an exp ~24h out, and Plaud started returning -419 for it about two minutes later. Because tokenExp still looked fresh, no re-mint was attempted — so every subsequent poll failed with -419 for as long as the cached exp stayed outside the refresh skew.

Observed state while stuck:

{"lastError":"Plaud list returned status=-419 msg=workspace token expired","authRequired":false}

authRequired: false, so the poller isn't paused either — it retries and fails every cycle. Since ensureFreshWorkspaceToken() won't re-mint and nothing forces it, the only ways out I could find were to wait for the cached exp to reach the refresh skew (up to ~24h), or to stop the app, hand-edit token to null in settings.json, and restart.

I don't know why Plaud stops honouring a WT well before its exp — that's server-side and I can't see it from here. Whatever the cause, the client-side gap is the same: a WT can be rejected while tokenExp still looks fresh, and today nothing re-mints in that window.

Fix

Treat -419 like a 401 and reuse the recovery that already exists. PlaudAuthError is already imported in poller.ts, so this is one if.

If the forced re-mint also fails, behaviour is unchanged from any other auth failure: handlePollError sets authRequired and pauses for a reconnect.

Testing

Verified on a first-party install that was stuck in the state above (-419 every cycle, authRequired: false):

  • After applying the patch and restarting, the poll recovered on its own — no reconnect and no manual settings edit.
  • Confirmed the re-mint actually happened rather than the WT being fixed some other way: tokenExp moved from the value that was taking -419 to a newly minted one ~12 minutes later, which is ensureFreshWorkspaceToken(true) running from the new branch.
  • The recovery is silent on success: -419 never reaches the log.
  • After recovery a full library synced normally and audio_ready fired for every recording (all 200 in webhook_log).
  • pnpm -C server build passes.

Context

I'm using Applaud to mirror recordings to disk and drive a local, offline transcription service off the audio_ready webhook, instead of relying on cloud transcripts. That flow depends on the poller staying alive unattended, which is how I hit this: nothing synced for what would have been ~24h, with no indication anything was wrong except a repeating error in the Applaud log.

Happy to adjust the approach — e.g. doing the mapping inside plaudFetch / the client layer instead of the poller so any endpoint benefits, or covering other negative status values if you know of more that mean "auth". -419 is the only one I've observed.


🤖 Generated with Claude Code

lealwtf and others added 2 commits July 15, 2026 11:22
Plaud rejects an expired workspace token two ways: HTTP 401, and HTTP 200
with `{status: -419, msg: "workspace token expired"}` in the body. Only the
401 path reached `PlaudAuthError`, so `runOnce`'s force-re-mint recovery
never fired for -419 and the poll failed with a generic error instead.

It could not self-heal either: `ensureFreshWorkspaceToken()` early-returns
while the cached WT's `exp` claim still looks fresh, but Plaud can begin
rejecting a WT long before that claim expires — leaving every poll failing
until the cached `exp` finally hit the refresh skew, up to ~24h later.

Map -419 to PlaudAuthError so the existing recovery re-mints and retries.
If that retry also fails, behaviour is unchanged: authRequired pauses for
a reconnect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The comment asserted the stuck window is "~24h", which is the JWT exp of one
observed token, not a property of the API. State the real invariant instead:
the poll stays broken while the cached tokenExp sits outside the refresh skew,
because Plaud can reject a WT well before the exp it was minted with.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rsteckler

Copy link
Copy Markdown
Owner

Hi @lealwtf - Thanks for the PR.

Which token were you using? There are three I'm aware of:

  1. tokenstr, which means you were using the previous Applaud version (0.5.10).
  2. The UT/URT pulled from the api cookies (using the 0.5.11 instructions).
  3. Something else.

I've seen this 419 behavior before, but only when trying to use the UT/URT pulled from local storage (rather than api cookies). I believe what's happening is that you're grabbing the URT from the web session (rather than API cookies). You mint a new token using the URT and it gets an expiry of 24h. Then the web.plaud.ai session mints a new UT from the same URT and sees it's invalid because you already used it. So it re-auths, invalidates your entire chain of tokens, and creates a new URT/UT combo.

The api.plaud.ai cookie approach outlined in the 0.5.11 instructions avoids that problem. I also tried what you have in this PR - retry on the 419, but it ends up "fighting" with the web plaud session and they both start minting new tokens every few minutes.

Let me know what version/tokens you're using!
Thanks!

@lealwtf

lealwtf commented Jul 16, 2026

Copy link
Copy Markdown
Author

Thanks — and your read looks right. Answering the question first, then correcting myself.

Which tokens: 0.5.11 (@applaud/server@0.5.11), authMode: "first_party", UT/URT auto-detected from Chrome by the 0.5.11 wizard — the found Plaud first-party cookies (browser: Chrome, profile: Default) path. Not tokenstr, not localStorage.

I have to walk back my "Testing" section. I claimed the poll recovered and stayed stable, and I'd started drafting a reply arguing that -419 recurs without a web session — my install re-minted at 14:14 and again at 18:08, and with WT_REFRESH_SKEW_MS at 5 minutes and tokenExp ~20h out both times, only the forced path can mint there, so I read that as "recovers on its own, a few hours apart, no fight."

Then I actually checked instead of assuming. Chrome history:

2026-07-15 16:59:46Z  https://web.plaud.ai/
2026-07-15 14:05:31Z  https://web.plaud.ai/
2026-07-15 14:02:29Z  https://api.plaud.ai/auth/refresh-user-token
2026-07-15 14:01:36Z  https://web.plaud.ai/login

The web app was open at 16:59 — right between the two re-mints. And at 14:02:29 it hit auth/refresh-user-token itself, 26 seconds before Applaud minted the WT that took -419 at 14:04:50. That's your mechanism, in my own logs. So I never had a clean no-web-session window, and nothing I measured contradicts your explanation. Sorry for the noise — that section shouldn't have been stated the way it was.

I also can't tell you how often it re-minted, because the recovery path logs nothing on success. So your "both start minting every few minutes" is something this patch would do invisibly. That's a real flaw in it as written, independent of whether the root cause is the token source.

The one thing I'd still like your read on, because it's the part I can't square with the api-vs-web framing. In my profile the cookies aren't host-scoped — querying Chrome for exactly what readPlaudCookieRows selects:

host_key    name      bytes  created
.plaud.ai   pld_urt     435  2026-07-15 14:01:47
.plaud.ai   pld_ut      403  2026-07-15 14:01:47

distinct host_keys: ['.plaud.ai']

One row each, on .plaud.ai — domain-wide, so api.plaud.ai and web.plaud.ai are handed the same pld_ut/pld_urt. If that's what the 0.5.11 flow normally produces, then I don't see how I could be holding a different URT than the web app — there's only one pair, and resolve() has one candidate to pick from. Which would mean the api-cookie approach and the web session share a URT by construction, and the fight is available to anyone who leaves web.plaud.ai open.

Is .plaud.ai what you'd expect there, or is the wizard supposed to land an api.plaud.ai-scoped cookie? If it's the latter, then something on my side landed differently and that's the actual bug — my -419 handling is treating a symptom of it.

Happy to close this. If you'd rather keep a version of it, I'd rework it to log the forced re-mint and refuse to force one if it already did within the last N minutes — pause with authRequired instead — so it can't feed a loop even when a web session is competing. But if the real fix is at the token-source layer, this PR is the wrong place for it and I'll close.

@rsteckler

Copy link
Copy Markdown
Owner

You're right that pld_urt is host-scoped to .plaud.ai, but you can't see it on web.plaud.ai because it's ALSO path-scoped to api.plaud.ai/auth/refresh-user-token.
image

0.5.11 should be pulling that cookie automatically on mac/linux. If that's not happening and you're seeing a 24h expiration, please let me know. On Windows, the cookie store is encrypted and I can't automatically pull the cookie without impersonating Chrome. On Windows, the only path is manually telling the user how to see the cookie by visiting
api.plaud.ai/auth/refresh-user-token and copy/pasting it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants