Skip to content

fix(fly-io): redact sensitive fields in GraphQL add-on responses - #867

Merged
Mkassabov merged 10 commits into
alchemy-run:mainfrom
mattespapier:fix/fly-graphql-success-redact
Oct 21, 2026
Merged

Mkassabov merged 10 commits into
alchemy-run:mainfrom
mattespapier:fix/fly-graphql-success-redact

Conversation

@mattespapier

Copy link
Copy Markdown
Contributor

A successful Fly GraphQL add-on call returns the add-on's password and publicUrl (a connection URL with the password in it) as plain strings, though services/addons.ts marks both T.SensitiveValue and their types say string | Redacted<string> | null. The REST protocols (core's makeRestProtocol) and the Sprites decoder pass the success value through wrapSensitive; graphqlDecode returns the validated payload as read. Live on Fly.io, an addOn({ id }) read of an Upstash Redis add-on on #839's head returned both as plain strings, and the password showed in inspect(result) (table under Tested).

Depends on #839, which edits the same function's error paths. Review the last two commits (6d76c16bc, be7cad36c); the five before them are #839 at 4db9f2b7f.

Change

  • graphqlDecode returns wrapSensitive(outputAst, validated) in lenient and strict mode alike, so addOn, addOns, organization, createAddOn, updateAddOn and resetAddOnPassword return password and publicUrl as Redacted, nested ones such as createAddOn.addOn included. Unmarked fields stay plain.
  • One new test block: addOn and createAddOn in both modes return both fields redacted, Redacted.value gives the original string, name stays plain, and the secret appears nowhere in inspect(result).

Tested

The four new cases fail on #839's head, each on Redacted.isRedacted(...password), and pass here.

Live, Fly.io, one Upstash Redis add-on (upstash_redis, Pay-as-you-go) created in a personal org for the run, read once through each head, and deleted (2026-10-09 UTC):

Call password publicUrl
addOn({ id }), #839 head plain string (32 chars) plain string (90 chars)
addOn({ id }), this branch Redacted (32 chars) Redacted (90 chars)
createAddOn, this branch Redacted Redacted

On #839's head the password showed in inspect(result) of the addOn read; on this branch it didn't. The createAddOn result still shows it, through environment (below).

An earlier run against a Fly Tigris storage add-on showed only that a live reply still decodes: Tigris add-ons have no password or publicUrl, so both came back null.

Not covered

  • environment is not marked sensitive. createAddOn returns the add-on's credentials in it, plain: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY for a Tigris storage add-on, REDIS_PASSWORD and REDIS_URL for Redis (both seen live). Marking it would change nothing at runtime: wrapSensitive wraps only string members, and environment is an S.Unknown object. Redacting its values needs a new shape (a string map with sensitive values), which changes what Alchemy's Fly/Bucket.ts reads, so it is left for a follow-up with a companion Alchemy change.

Alchemy needs no change: Fly/Redis.ts already unwraps publicUrl whether or not it is Redacted and never reads password, and Fly/Bucket.ts reads neither.

Checked: pnpm vitest run packages/fly-io (36 pass; 32 on #839's head) and packages/core (731 pass), lint, format:check, tsc -b packages/fly-io --noCheck false; Alchemy main (41a0a4e74) with the submodule at this head: tsc -b with no errors and lint:providers.

mattespapier and others added 9 commits October 9, 2026 23:46
A 2xx body for an operation whose output has a sensitive member reached
the caller unredacted when it did not match the output schema. In strict
mode, `<Sdk>ParseError.body` carried the raw text or the parsed JSON, so a
response that drifted from the spec put the secret into an error that
gets logged. In lenient mode, a non-JSON body (one cut off mid-secret,
for example) came back as the success value.

`hasSensitiveMember(ast)`, beside `wrapSensitive` in `protocol-rest`,
reports whether any member reachable from an output is marked
`SensitiveValue`. It looks inside map values too. For such outputs
`makeRestProtocol` fails a non-JSON 2xx body in both modes and builds
every parse error with `body: "[REDACTED]"`, the sentinel ACME and
ZeroSSL already use; the cause stays the schema error. The whole body is
withheld because in a mismatched body the secret may sit at a key the
schema does not model. Outputs without sensitive members behave as
before.

Protocols that do not go through `makeRestProtocol` are unchanged.
`hasSensitiveMember` visited each list element but checked the
`SensitiveValue` mark only on an object's members, so an output whose
only secret is a list element (a stored header map typed
`Record<string, Array<string>>` whose elements are marked) counted as not
sensitive, and a parse failure reported its body unredacted. It now
checks the mark on each list element too, as it does on a member or a
map value.

No generated output marks a list element today, so no SDK changes until
the generator and `wrapSensitive` honor such a mark.
Stripe's hand-written protocol wires `validateResponse` itself, so the
core fix does not reach it. A 2xx body for an output with a sensitive
member (`CreateWebhookEndpoint`'s `secret`, `CreateEphemeralKey`'s
`secret`) that did not match the schema put the secret into
`StripeParseError.body` in strict mode, and a non-JSON body (one cut off
mid-secret) came back as the success value in lenient mode.

It now does what `makeRestProtocol` does: for an output where
`hasSensitiveMember(outputAst)`, a non-JSON 2xx body fails in every mode
and every parse error reports `body: "[REDACTED]"`. Outputs without
sensitive members behave as before.
Fly.io's GraphQL and Sprites decoders wire `validateResponse` themselves,
so the core fix does not reach them. A 2xx body for an output with a
sensitive member (`addOn`'s `password` and `publicUrl`,
`createOrganizationToken`'s `token`) that did not match the schema put
the secret into `FlyIoParseError.body` in strict mode, and a non-JSON
body (one cut off mid-secret) came back as the success value in lenient
mode.

Both now do what `makeRestProtocol` does: for an output where
`hasSensitiveMember(outputAst)`, a non-JSON 2xx body fails in every mode
and every parse error reports `body: "[REDACTED]"`. The Sprites token
mint that runs before every Sprites operation but
`createOrganizationToken` reported `json ?? text` when the reply carried
no usable token, so a reply cut off mid-token leaked it from an ordinary
call such as `deleteSprite`; it now always reports `"[REDACTED]"`, since
its reply exists only to carry the token. Outputs without sensitive
members behave as before.
Only core's REST protocol, Stripe and Fly.io fail a non-JSON 2xx body for an output with sensitive members; Azure, Coinbase, MongoDB Atlas, Slack, STACKIT, Supabase and ZeroSSL decode it themselves and still return it as text in lenient mode. The decoder comments now say what they do (fail a non-JSON body, withhold the body from parse errors) instead of claiming the body is never returned unredacted, which a secret under a map value still is on success.
Every successful add-on call returned the add-on's `password` and
`publicUrl` (a connection URL with the password in it) as plain strings,
though their generated types say `string | Redacted<string> | null`.
The GraphQL decoder returned the validated payload as read, skipping the
`wrapSensitive` step the REST and Sprites decoders take.

`graphqlDecode` now passes the validated payload through
`wrapSensitive(outputAst, ...)` in lenient and strict mode alike, so the
fields marked `SensitiveValue` on the add-on output shapes in
`services/addons.ts` come back wrapped in `Redacted`, nested ones such as
`createAddOn.addOn` included. Unmarked fields stay plain, `environment`
among them.
`addOn` and `createAddOn`, called through a fake HTTP client in lenient
and strict mode, return `password` and `publicUrl` wrapped in `Redacted`,
with `Redacted.value` giving the original string; `name`, which is not
marked, stays plain, and the secret appears nowhere in the inspected
result. All four cases fail before the decoder change, on
`Redacted.isRedacted(...password)`.
Port the parse-error redaction onto alchemy-run#873's synchronous decoders:

- core: makeRestProtocol's decodeText reads `withholdBody`
  (hasSensitiveMember) from the per-operation DecodePlan.
- stripe: decodeResponse withholds the body the same way.

alchemy-run#873's strict checker returns a SchemaMismatch whose `actual` and message
carry the offending value, so a mismatch at a sensitive member put the
secret back into the parse error's cause. `withholdActual` (core
response-validation) replaces that value with "[REDACTED]"; every site
that withholds the body now withholds it from the cause too. A core test
covers a strict mismatch at the sensitive member itself.
@alchemy-version-bot

alchemy-version-bot Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Install the packages built from this commit:

@distilled.cloud

pnpm install https://pkg.distilled.cloud/@distilled.cloud/core/pr:867:af3e230
pnpm install https://pkg.distilled.cloud/@distilled.cloud/fly-io/pr:867:af3e230

Published Oct 21, 2026, 11:45 AM UTC. Expires Oct 28, 2026, 11:45 AM UTC, extended while this pull request is open.

…ss-redact

# Conflicts:
#	packages/fly-io/src/protocol.test.ts
@Mkassabov
Mkassabov added this pull request to the merge queue Oct 21, 2026
Merged via the queue into alchemy-run:main with commit 45e1f51 Oct 21, 2026
7 checks passed
@Mkassabov

Copy link
Copy Markdown
Collaborator

Thanks @mattespapier — merged after #839. Add-on password and publicUrl now come back Redacted from the Fly GraphQL decoder in both modes. The environment credentials gap you called out stays open for the follow-up.

-- claude

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