Skip to content

Reject duplicate connection names on create - #1346

Merged
RhysSullivan merged 17 commits into
mainfrom
connection-create-conflict
Aug 28, 2026
Merged

Reject duplicate connection names on create#1346
RhysSullivan merged 17 commits into
mainfrom
connection-create-conflict

Conversation

@RhysSullivan

@RhysSullivan RhysSullivan commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

connections.create with an existing (owner, integration, name) silently overwrote the stored credential — the provider item id derives from those three parts, so the secret was clobbered before the row write even ran. Creates now return a 409 ConnectionAlreadyExistsError (checked before any provider write, re-checked in-transaction for the create/create race), surfaced through POST /connections, the core tool (connection_already_exists), and the MCP plugin.

OAuth flows are deliberately unchanged: main's newConnection: true already resolves name collisions server-side on connect and reconnect re-mints on purpose, which supersedes this PR's original reconnect: true design. The manual credential submit is the one UI path that still hits the 409 and now shows it as a toast.

Normalized-name collisions (my-api-key vs my api key) are caught; same name under different owners coexist. Additive wire change (new 409 response), noted in the changeset.

Credential writes and compensation

Provider writes are ordered after the transactional row insert: only the create that wins the insert may touch the provider, so a losing create can never clobber the winner's (or a pre-existing connection's) secret. The writes then run inline, before tool production — GraphQL/MCP plugins do authenticated introspection via getValues() at tool-production time, so the credentials must exist by then or the catalog is discovered empty and never re-discovered.

If the write does not complete — failure, interruption, or defect — compensation removes the items already written and then the row, so no credential-less row survives to 409 every retry. Nothing in compensation is silent: a failed compensating delete makes the create fail with an error that names the stranded connection and keeps the original failure as its cause, and a provider with set but no delete gets an explicit warning naming the item that may be stranded.

Compensation identifies its own row by the storage surrogate row_id, deletes it with that id in the WHERE clause, and then — because deleteMany returns void — confirms the outcome with a read in the same transaction. If any row still holds the name, the guarded delete removed nothing and a concurrent remove/recreate interleaved: compensation skips all credential-item deletion and logs that the surviving connection owns the items. Only when no row remains does it undo the items it wrote.

An earlier revision deferred the credential write past an enclosing transaction's commit via an after-commit hook. That was reverted: the deferral made nested creates produce their tool catalog before the credentials existed (silently committing an empty catalog that is never re-discovered), and exactly-once execution of the hook is not guaranteed under interruption. External credential stores cannot join a database transaction; pretending otherwise created worse failure modes than the pre-existing window below.

Known limitations

Provider credential stores expose no conditional delete, so perfect cleanup under a concurrent remove/recreate is impossible at this layer. The residuals below are accepted deliberately rather than papered over with more machinery.

  • Nested-transaction orphans (pre-existing). transaction nests by pass-through, so a create running inside an enclosing plugin ctx.transaction writes credentials before the OUTER commit. If that transaction rolls back, the row vanishes with it but the credential items survive as orphans at deterministic ids, overwritten by the next same-shaped create.

  • Skipped cleanup leaves orphans. Under concurrent remove/recreate, compensation may skip item deletion, leaving orphaned credential values at the deterministic item ids. Orphans are inert without a row and the next same-shaped create overwrites them; orphans are preferred over the alternative, clobbering a live successor's secrets. A successor that inserts only after the compensation transaction commits can also still interleave with the item deletes.

  • Partial successor over a stranded predecessor. A successor that overwrites one variable, fails before the next, and then also fails its own compensating row delete leaves a stranded connection able to resolve one stale predecessor value. Closing this requires provider-side conditional deletes, which do not exist. The stranded state is not silent: the create fails with a typed StorageError naming the connection.

  • Unconfirmed compensating delete on non-transactional adapters. On an adapter without interactive transactions (Cloudflare D1 runs interactiveTransactions: false; every statement auto-commits, no rollback) the guarded delete may already have committed when the confirmation read fails. The row state is then unknown — deleted or stranded — so compensation skips all credential-item deletion and the create fails with a typed StorageError that reports the unconfirmed state instead of claiming a stranded row; any items already written are left as inert orphans.

connections.create silently upserted when a connection with the same
(owner, integration, name) already existed, clobbering the stored
credential and metadata. Fail with ConnectionAlreadyExistsError (409)
instead, checked before any provider write and re-checked inside the
insert transaction. OAuth minting keeps its intentional re-mint upsert.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 7, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
executor-cloud 05a9e71 Aug 28 2026, 10:16 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 7, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
executor-marketing 05a9e71 Commit Preview URL

Branch Preview URL
Aug 28 2026, 10:14 PM

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Cloudflare preview

Torn down — the PR is closed.

@pkg-pr-new

pkg-pr-new Bot commented Jul 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

@executor-js/cli

npm i https://pkg.pr.new/@executor-js/cli@1346

@executor-js/config

npm i https://pkg.pr.new/@executor-js/config@1346

@executor-js/execution

npm i https://pkg.pr.new/@executor-js/execution@1346

@executor-js/sdk

npm i https://pkg.pr.new/@executor-js/sdk@1346

@executor-js/codemode-core

npm i https://pkg.pr.new/@executor-js/codemode-core@1346

@executor-js/runtime-quickjs

npm i https://pkg.pr.new/@executor-js/runtime-quickjs@1346

@executor-js/plugin-file-secrets

npm i https://pkg.pr.new/@executor-js/plugin-file-secrets@1346

@executor-js/plugin-graphql

npm i https://pkg.pr.new/@executor-js/plugin-graphql@1346

@executor-js/plugin-keychain

npm i https://pkg.pr.new/@executor-js/plugin-keychain@1346

@executor-js/plugin-mcp

npm i https://pkg.pr.new/@executor-js/plugin-mcp@1346

@executor-js/plugin-onepassword

npm i https://pkg.pr.new/@executor-js/plugin-onepassword@1346

@executor-js/plugin-openapi

npm i https://pkg.pr.new/@executor-js/plugin-openapi@1346

executor

npm i https://pkg.pr.new/executor@1346

commit: 05a9e71

health-checks swapped in a dead key by re-creating the connection; the
fixture server now revokes the token instead. The cloud credentials
scenario pinned replace-in-place; it now pins the conflict rejection.
The conflict error message is a getter, so it does not survive the wire;
match the tag client-side and rebuild the message, mirroring the
integration already-exists handling. Adds a browser scenario recording
the rejected duplicate.
The callback mint upserts by (owner, integration, name), so a fresh
oauth.start aimed at an existing name silently replaced that connection
when the user returned from the provider. start now rejects the name up
front and complete re-checks it (a connection can appear mid-flight),
both leaving the existing connection untouched. Reconnect flows pass
reconnect: true and keep re-minting the same connection.
The popup flow hook dropped the reconnect flag from its start payload, so
every Reconnect that went through it hit the new oauth.start guard: the
popup opened and closed with no message. Forward the flag, toast the
reconnect handoff failure, and pre-check the connection list before
opening a popup for fresh BYO/CIMD/DCR connects so a taken name surfaces
as an error without the window blip.
Keep the connections.create conflict (409 ConnectionAlreadyExistsError) and
drop the branch's OAuth-side guard: main now resolves a taken name for fresh
OAuth connects with `newConnection`, which suffixes to the next free name, so
the `reconnect` discriminator and the oauth.start/complete rejections are
redundant and would break reconnect.
@RhysSullivan
RhysSullivan marked this pull request as ready for review August 28, 2026 22:21
@RhysSullivan
RhysSullivan merged commit 10e16a5 into main Aug 28, 2026
44 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