Skip to content

fix(figma): try the bridge before demanding a direct login - #60

Merged
owjs3901 merged 1 commit into
mainfrom
fix/bridge-before-direct-login
Sep 14, 2026
Merged

owjs3901 merged 1 commit into
mainfrom
fix/bridge-before-direct-login

Conversation

@owjs3901

Copy link
Copy Markdown
Contributor

What

Reported from production: devup-mcp keeps demanding the direct connection even when the bridge plugin is attached and could serve the whole export. It does — and the demand happens before anything looks at the bridge.

start_operation_scoped_tracked asked the OAuth backend for a token before it asked anything whether a token was needed:

let auth_status = self.services.auth.status().await?;
if auth_status == AuthStatus::Disconnected {
    return Err(/* DEVUP_AUTH_REQUIRED: "requires devup_figma_auth login" */);
}

That inverts the two paths. The bridge spends no Figma allowance and needs no credential of any kind; direct is metered and a single screen costs several reads. So whoever had attached the plugin — precisely to stay off the metered path — was told to go and authorize the metered path first, and the export never started, on a file every read of which the plugin was sitting there ready to serve.

After #57 moved node-scoped metadata onto the bridge, nothing in a tsx export needs the remote path at all. The collector only ever enqueues metadata (bridged) and screenshot (referencePng only, opt-in) — VariableDefs/DesignContext/CodeConnectMap are never constructed on the collection path. So that refusal stood between an attached plugin and a finished screen for no remaining reason.

Change

FigmaUpstream::serves_without_credentials(file_key) — default false, BridgeFigmaClient answers state.has_plugin(file_key), FallbackUpstream delegates. Per file key, not per process: a plugin holding another file open says nothing about this one.

The login is now required only when no plugin is holding this file. A read the bridge genuinely cannot serve — a file-scope metadata read, or referencePng's get_screenshot — still refuses on its own, where the reason belongs to that read instead of condemning the whole export before it starts.

The refusal, when it does fire, now offers the cheaper path first.

doctor knew only about direct

Which left the only instrument for diagnosing the connection able to give exactly one answer — run login — to every question, including the ones whose real answer was "run the plugin" or "the listener never bound". It now reports preferredPath: "bridge" and a paths.bridge block separating the three states that need three different repairs:

state meaning repair
listening: false port taken or switched off no plugin will ever reach this process
listening: true, nothing attached door open, nobody walked through run the plugin (the port is named in the reason)
attachedFiles: [...] working none — and it names the files, because a plugin open on the wrong file looks identical from outside

Evidence

Driven against the release binary over real stdio MCP with a real WebSocket plugin and no token stored at all:

=== CASE 1: bridge switched off ===
listening=False available=False port=
reason: This process is not listening for the bridge plugin. Either
DEVUP_FIGMA_BRIDGE_PORT is off/0, or the port was already taken ...

=== CASE 2: bridge listening on 19931, no plugin attached ===
listening=True available=False port=19931 attached=
reason: The bridge is listening on 127.0.0.1:19931 but no plugin is attached ...

=== CASE 3: attach a plugin, then doctor + export with NO OAuth ===
websocket state: Open
listening=True available=True port=19931 attached=FileKey123
reason: A plugin is attached. Reads for the files listed in attachedFiles are
served through it, spending no Figma allowance and needing no login.

--- export with a disconnected direct path ---
RESULT: no DEVUP_AUTH_REQUIRED  <-- the auth gate was passed on the bridge alone
error codes seen: DEVUP_FIGMA_DIRECT_UNAVAILABLE

The remaining error is the fake plugin declining the job, i.e. the export reached the read. Before this change the same call stopped at DEVUP_AUTH_REQUIRED without reaching the bridge at all.

Tests

  • source_orchestration::an_attached_bridge_collects_without_demanding_a_direct_login — new; verified failing before the change with exactly the production error.
  • source_orchestration::auto_asks_to_be_logged_in_rather_than_starting_oauth — extended: the refusal has to offer the bridge first.
  • bridge_transport::a_plugin_holding_the_file_makes_it_readable_without_credentials — new; per-file, so another file's plugin does not count.
  • diagnostics::doctor_prefers_the_bridge_and_separates_its_three_states — new; also asserts a bridge problem is never answered with devup_figma_auth.

Gates

Gate Result
cargo fmt --all -- --check pass
cargo clippy --locked --workspace --all-targets --all-features -- -D warnings pass, zero warnings
cargo test --workspace --release pass, 0 failed
cargo build --release + --version + --self-check pass

A collection asked the OAuth backend for a token before it asked anything
whether a token was needed, and refused outright when there wasn't one. That
inverted the two paths: the bridge spends no Figma allowance and needs no
credential of any kind, while direct is metered and a single screen costs
several reads. So whoever had attached the plugin - precisely to stay off the
metered path - was told to go and authorize the metered path first, and the
export never started, on a file every read of which the plugin was sitting
there ready to serve.

serves_without_credentials answers per file key rather than per process,
because a plugin holding another file open says nothing about this one. The
login is required only when no plugin is holding this file. A read the bridge
genuinely cannot serve still refuses on its own, where the reason belongs to
that read instead of condemning the whole export before it starts.

devup_figma_auth doctor knew only about direct, which left the only instrument
for diagnosing the connection able to give exactly one answer - run login - to
every question, including the ones whose real answer was "run the plugin" or
"the listener never bound". It now reports preferredPath and a paths.bridge
block separating the three states that need three different repairs: not
listening, listening with nothing attached, and attached with its file keys.
@owjs3901
owjs3901 merged commit b21a105 into main Sep 14, 2026
8 checks passed
@owjs3901
owjs3901 deleted the fix/bridge-before-direct-login branch September 14, 2026 07:14
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