Skip to content

TS19: parse outside answers and stored data at the boundary - #29

Merged
Finesssee merged 3 commits into
mainfrom
ts/ts19-outside-parsers
Oct 11, 2026
Merged

Finesssee merged 3 commits into
mainfrom
ts/ts19-outside-parsers

Conversation

@Finesssee

@Finesssee Finesssee commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

TS19 of the strict-TypeScript program. Scope on the Twitch side is the OAuth and Helix answers that server/auth.ts reads; the Twitch reads in eventsub.ts and accounts.ts stay unparsed (see Left alone). Answers from outside services and data read back from storage are now checked at the boundary by hand-written parsers in server/parse.ts. They use the same style as the body parsers: each returns Parsed<T>, and no dependencies are added.

When a parse fails, the route answers with the error it already gives when that upstream fails. It never crashes and never coerces a value. A valid answer passes through as the same object, so it behaves as before.

Parsers (server/parse.ts)

Parser Reads Used in
parseTwitchTokens POST /oauth2/token: access_token and refresh_token strings, object kept as sent (it is sealed whole) auth.ts sign-in, access() refresh
parseTwitchValidation /oauth2/validate: client_id string, user_id string when sent, scopes string array or null when sent auth.ts sign-in, access()
parseHelixUsers /helix/users: data rows with id, login, display_name strings, created_at and broadcaster_type strings when sent auth.ts sign-in identity and owner lookup
parseHelixPage any Helix list: data an array auth.ts sign-up past-broadcast check
parseModerators /helix/moderation/moderators: data rows that are objects, user_id a string when sent (a row without one names nobody) access()
parseChannelRows AuthStore /list: {key, value} rows, each value a channel record (id, login strings, enabledAt number, pausedAt number, pausedBy owner/broadcaster, review {reasons, at} when set) channels.ts listRecords
parseChannelOrigins CHANNEL_ORIGINS: JSON object of channel to URL, answers each origin hosts.ts channelOrigins
parseUploadMeta custom_characters.meta: a JSON object uploads.ts catalog
parseBackupExport a daily backup: exportedAt string, profiles/purchases/builds rows with a userId string, profiles with username backups.ts readBackup
parseCfEnvelope, parseAnalytics, parseCfDeployments, parseCfVersions Cloudflare REST and GraphQL answers developer.ts cf(), usage, versions
parseGhTree, parseGhContent, parseGhRef, parseGhSaved, parseGhPulls, parseGhPr, parseGhRuns, parseGhPull, parseGhMerge, ignoreAnswer GitHub REST answers developer.ts gh() and every GitHub route

gh() and cf() now take the parser as an argument. ApiResult<T> is {ok:true, data:T} or {ok:false, status, message}, so the as ApiResult<T> casts and the local REST shape types are gone.

Changed responses

Only answers that are not JSON or have the wrong shape change. Every row below is pinned by a test.

Route or function Input Before After
/auth/callback Helix users {} 403 Twitch identity mismatch 502 Twitch identity validation failed
/auth/callback token answer <html> uncaught exception 502 Twitch token exchange failed
/auth/callback token answer without access_token 303 signed in 502 Twitch token exchange failed
/auth/callback validate answer <html> uncaught exception 502 Twitch identity validation failed
/auth/callback validate user_id: 2 (number) 403 Twitch identity mismatch 502 Twitch identity validation failed
/auth/callback owner lookup <html> uncaught exception 303 signed in (lookup skipped, as a failed one is)
/auth/callback?signup=1 past-broadcast list {} review reasons include never_streamed unchecked
access() moderators {pagination:{}} moderator:false, cached a minute throws Current moderator role cannot be verified, nothing cached
access() refresh answer without refresh_token went on, then Twitch authorization unavailable throws Broadcaster must reconnect Twitch
access() validate scopes: "moderation:read" (a string) passed (String.includes matched), mod granted throws Twitch authorization unavailable
listRecords a record with enabledAt: "1000" next to good ones resolved with the bad row resolves with the good rows only and logs registry records skipped channel:<login>
GET /api/dev/channels, /api/channels, /api/picker, sign-up, backups, EventSub routing same the bad row included the bad row left out, every other channel unchanged
channelOrigins ["https://chat.miolaf.xyz"] {"0":"https://chat.miolaf.xyz"} {}
room /catalog meta ["a"] {"0":"a", id, url, custom} {id, url, custom}
readBackup export with userId: 11 the raw object {ok:false, error:"invalid_backup"}
readBackup data that is not gzip threw {ok:false, error:"invalid_backup"}
GET /api/dev/backups?…&day= export of the wrong shape 200 download 503 upstream_error (the route's existing error for an unreadable backup)
GET /api/dev/runs 200 <html> 503 upstream_error 502 github_error List workflow runs: GitHub 200 unreadable answer
GET /api/dev/code/tree 200 <html> 503 upstream_error 502 github_error Read tree: GitHub 200 unreadable answer
GET /api/dev/code/file 200 <html> 400 not_a_file 502 github_error Read file: GitHub 200 unreadable answer
POST /api/dev/hotfix PR create answer <html> 503 after the workflow was dispatched 202 pr:null, prError:"Pull request not opened; open it manually"
POST /api/dev/promote merge answer without sha 202, production dispatched with no sha 502 github_error Merge pull request (it may have merged; check GitHub): GitHub 200 unreadable answer, nothing dispatched
GET /api/dev/versions deployments <html> 503 upstream_error 200, that script's entry {deployments:[], versions:[], error:"Cloudflare API error 200"}
usage() (diagnostics) Analytics row null error:"Analytics unavailable", no status error:"Analytics query failed", status:200

Tests (tests/parse.test.ts)

Each parser has valid, wrong-type and missing-field cases with literal expected values. These tests failed on the parent commit and pass on this branch (red run: 17 pass, 16 fail).

  • Twitch answer parsers: tokens, validation, users, a Helix page, moderators
  • registry rows: id and login strings, enabledAt a number, the pause and review fields typed when sent; a bad row is skipped by key
  • CHANNEL_ORIGINS and upload meta: JSON objects of the right values, else refused
  • backup exports: profiles, purchases and builds as the routes read them
  • Cloudflare answer parsers: the envelope, Analytics rows, deployments, versions
  • GitHub answer parsers: tree, contents, refs, saves, pull requests, runs, merges
  • sign-in: a Twitch answer of the wrong shape gets the existing upstream error
  • sign-up: an unreadable past-broadcast list makes the streamer check say unchecked
  • moderator check: a Twitch answer of the wrong shape refuses with access()'s existing errors
  • registry: a channel record of the wrong shape is left out and logged, and the other channels still list
  • CHANNEL_ORIGINS: anything but an object of URLs is no channel domains
  • custom character catalog: stored meta that is not an object adds no fields
  • daily backups: a stored export of the wrong shape is unreadable, and the backups route's upstream error
  • GitHub: an ok answer of the wrong shape gets the route's GitHub error
  • Cloudflare: an unreadable deployments answer is that script's API error
  • request usage: an Analytics row of the wrong shape is a failed query

bun run check: 359 tests, 359 pass.

Judgment calls

  • A bad registry record is skipped, not fatal. parseChannelRows keeps the good rows and names the bad keys; listRecords logs them and lists the rest. A list that is not an array is still Auth storage unavailable. Refusing the whole list would have taken the picker, sign-up, EventSub routing and the backup cron down for every channel over one corrupt record.
  • parseModerators skips a row without user_id instead of refusing the answer. That row can't match the user, so the answer stays "not a moderator", which still fails closed. A user_id of the wrong type, a non-object row or a missing data is refused. tests/registry.test.mjs serves a Helix users body for the moderators URL and depends on this.
  • user_id on validate is optional, because an app token has none. It then fails the existing identity check (403) as before.
  • The owner lookup reuses parseHelixUsers, so it now needs login and display_name too. Real Helix answers always carry them.
  • Upload meta only has to be an object. The catalog spreads whatever validateUpload stored.
  • An unreadable backup answers 503 upstream_error. The route throws into the handler's catch, which was already the answer for a stored backup that wasn't JSON.
  • BackupExport.exportedAt is retyped from number to string. exportHead writes the ISO time.
  • GitHub ok answers of the wrong shape use the message unreadable answer and go through ghError, so they answer 502 github_error instead of 503 or not_a_file.
  • Cloudflare deployment annotations and version metadata, and GitHub save content/commit, may be null, matching what those APIs send.
  • Sign-in Twitch answers of the wrong shape answer 502 with the existing messages.

Left alone (outside scope)

  • handleAuth is returned without await inside the worker's try, so a throw there escapes the catch.
  • Remaining unparsed outside reads: the Helix read in accounts.ts, appToken in eventsub.ts, the room JSON reads in developer.ts (exportData, diagnostics, restore), listBackups rows, AuthStore record()/consume() reads, and the catalog and board reads in worker.ts.
  • The twitch() stub in tests/registry.test.mjs answers the moderators URL with a users body.

Audit

An Opus audit at d9d4293 found no blockers. Fixed in 33d297f:

  • One bad registry record no longer refuses every channel (above).
  • promote, after an unreadable merge answer, says the merge may have happened.
  • Registry tests pin pausedBy: "broadcaster", pausedAt without pausedBy, and an extra field passing through.
  • This body now names the Twitch scope (auth.ts only).

Kept as is: the owner lookup needs login and display_name (real Helix answers carry both).

@coderabbitai

coderabbitai Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: Repository: nesszer/pixfray/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 26c792eb-e269-498e-b387-ca6687b52ef5

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Twitch OAuth and Helix answers, the AuthStore channel registry, GitHub and
Cloudflare REST answers, CHANNEL_ORIGINS, daily backups and custom character
meta now go through hand-written parsers in server/parse.ts. An answer that is
not JSON or has a field of the wrong type gets that route's existing upstream
error instead of a crash or a coerced value; valid answers behave as before.
…ly backups module

parse.ts is in the strict-tests-shared program, which has no Workers types; importing the type from backups.ts
pulled CompressionStream into it (TS2345).
@Finesssee
Finesssee force-pushed the ts/ts19-outside-parsers branch from 67f062e to d9d4293 Compare October 11, 2026 19:48
… every channel; promote says a merge may have happened
@Finesssee
Finesssee merged commit b46170f into main Oct 11, 2026
3 checks passed
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.

1 participant