Skip to content

feat(errors): typed errors with status and URL for error responses - #43

Merged
PhilflowIO merged 3 commits into
mainfrom
feat/typed-auth-error
Oct 9, 2026
Merged

PhilflowIO merged 3 commits into
mainfrom
feat/typed-auth-error

Conversation

@PhilflowIO

@PhilflowIO PhilflowIO commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Closes #42

What

New errors (src/util/authError.ts, exported from the package root):

  • DAVResponseError: a subclass of Error with code: 'TSDAV_RESPONSE_ERROR', status and url. isDAVResponseError checks the class or the code.
  • DAVAuthenticationError: a subclass of DAVResponseError with code: 'TSDAV_AUTHENTICATION_FAILED'. isDAVAuthenticationError checks the class or the code.
  • Checking the code as well means both guards still work when a bundler loads two copies of tsdav.

Where they are thrown. Messages are unchanged everywhere; any of these answered with 401 throws DAVAuthenticationError instead:

  • assertDAVResponses, used by calendar and address-book discovery (Calendar discovery failed: …)
  • assertDAVProperty
  • collectionQuery
  • isCollectionDirty
  • fetchPrincipalUrl: a 403 now keeps its status
  • fetchHomeUrl

OAuth:

  • getOauthHeaders adds failure: { status, url } to its result when the token endpoint answers with an error status. On success it returns exactly the fields it did before.
  • The client throws DAVAuthenticationError for a token endpoint answering 400 or 401 (RFC 6749 5.2), and DAVResponseError with the status for anything else (429, 404, 5xx).
  • The return values of fetchOauthTokens and refreshAccessToken are unchanged.

Elsewhere:

  • createAccount keeps a credentials error across root-URL candidates by its type; it used to check message.startsWith('Invalid credentials').
  • Documented in docs/docs/helpers/authHelpers.md. CHANGELOG has an Unreleased section. Version and tag are left to the release.

Why

dav-mcp (PhilflowIO/dav-mcp#134) needs to tell a wrong password from a forbidden resource, a rate limit, a wrong URL or a server failure, without reading message text. That holds at login and later on, e.g. when an OAuth refresh token expires mid-session. Against Baïkal a 401 on /dav.php/ is followed by a 405 on the server root, so only the thrown error knows the login was refused.

Tests

  • src/__tests__/unit/authError.test.ts covers:
    • the exports, and the guards recognising a copy by its code
    • fetchPrincipalUrl: 401 is an auth error with the unchanged message; 403 is a DAVResponseError 403 with the unchanged message
    • createAccount with 401 followed by 405 (Baïkal): typed 401
    • assertDAVResponses with 401, 403 and 500
    • collectionQuery with 401
    • token endpoint: 400 on refresh and 400 on the authorization-code grant are auth errors; 429, 404 and 503 are DAVResponseErrors
    • getOauthHeaders on success: same fields as before
  • pnpm test: 27 files / 443 tests passed. pnpm typecheck, pnpm lint, pnpm test:examples and pnpm test:consumer all pass.
  • The committed dist/ matches a fresh pnpm build.
  • Not run: the live provider suites. dav-mcp ran live against Baïkal with this build.

A refused login was a plain Error whose only signal was its message, so
callers matched "Invalid credentials" to tell a wrong password from a
server that is down or not a DAV server, and createAccount did the same
when it kept the 401 across root-URL candidates. Against Baikal a 401 on
/dav.php/ is followed by a 405 on the server root; only the error
knows the login was refused.

DAVAuthenticationError carries the status and the URL that refused,
with the old message. An OAuth token endpoint refusing the grant (4xx)
raises it too; getOauthHeaders reports the failed status so the client
can tell.

Closes #42
Discovery, collection queries and status checks threw a plain Error
carrying the status only in its message ("Calendar discovery failed:
401 ...", "Collection query failed: 401 ..."), so a caller still had to
read the text to tell a refused login from a forbidden resource or a
server failure. They throw DAVResponseError with the status and URL,
messages unchanged, and a 401 throws DAVAuthenticationError, which is
now a DAVResponseError too.

The token endpoint counts as refusing the credentials only on 400 or
401 (RFC 6749 5.2). A 429, a 404 from a wrong token URL or a 5xx is a
DAVResponseError with that status, and a 403 at login keeps its status
instead of becoming a bare "cannot find principalUrl".

Refs #42
@PhilflowIO PhilflowIO changed the title feat(auth): throw a typed error when the server refuses credentials feat(errors): typed errors with status and URL for error responses Oct 9, 2026
smartCollectionSync and fetchCalendarUserAddresses still threw a plain
Error, so a 401 on a sync REPORT read as anything but a refused login
and a 403 on the principal lost its status. They throw DAVResponseError
(DAVAuthenticationError on 401) with the status and URL; messages are
unchanged.

Refs #42
@PhilflowIO
PhilflowIO merged commit c984546 into main Oct 9, 2026
8 checks passed
@PhilflowIO
PhilflowIO deleted the feat/typed-auth-error branch October 9, 2026 09:11
@PhilflowIO PhilflowIO mentioned this pull request Oct 9, 2026

This branch was successfully deployed

1 active deployment
Production — 60b12e13 Deployed Oct 9, 2026 by PhilflowIO via automated-test (18) #27
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.

Throw typed errors with status and URL for error responses

1 participant