fix(connections): allow non-URL outputs in internal connection mode - #632
Merged
Hydralerne merged 2 commits intoAug 19, 2026
Merged
Conversation
Template outputs like JWT keys (SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY), passwords, and tokens are not network URLs. In internal mode, createConnection was unconditionally running toInternalUrl on every non-internal template output, causing toInternalUrl to fail on non-URLs and rejecting connections with 'Internal mode isn't available for this connection'. Only rewrite values that parse as valid network URLs; inject non-URL credentials verbatim.
…hich output threw Letting non-URL outputs through is right, but three things downstream were reading the rejection it removed as a signal. `writeBinding` chose internal-vs-public by catching the "internal mode isn't available" ValidationError. The first output it links is always a credential (`buildObjectStorageEnv` pushes the access key first), so that throw fired on every non-cloud MinIO bind and every mode-less bind deterministically landed on public — and the dashboard sends no mode, so that is the default path. With credentials no longer rejected, nothing throws and the bind silently flips to internal with the endpoint rewrite and reachability unchecked. It now asks `internalModeAvailable()` about the ENDPOINT output, the only one of the three that carries a host. `toInternalUrl` fell back to the service's FIRST declared endpoint whenever the resolved value had no port, and minio.json declares the console (9001) before the S3 API (9000) — so a routed MinIO handed the consumer `S3_ENDPOINT=https://minio:9001`, TLS against a plaintext console port, while the bind reported success and the Storage tab kept showing the working public URL. Only the output's own source names the container port (`publicUrl:minio:9000`), so `getOutputPort()` supplies it and it outranks the resolved URL's port — which is either absent (a routed `https://<host>`) or the HOST side of a published mapping (`19000` of `19000:9000`). `toInternalUrl` also carried `https://` onto the alias. East-west traffic terminates at the container, which serves plaintext; the certificate lives on the edge. An endpoint declared `kind: "http"` now drops to `http:`, so Kong resolves to `http://kong:8000` too. A DSN keeps its own scheme. Internal was never checked for co-location. `openship-<slug>` networks are per-host and `attachLinkedNetworks` only WARNS when the attach fails, so a cross-server internal link injected an alias that resolves nowhere and still deployed green. Both ends must now derive to the same server, and the cloud check reads the durable `cloudWorkspaceId` via `deriveProjectDeployTarget` instead of the per-deployment meta snapshot, which is absent on a project bound to cloud that has not redeployed since. Credentials are exempt: a JWT carries no host, so gating one on network topology would re-break what oblienGH-631 reported. `createConnection` and the predicate now share one resolver, so a caller that must choose a mode before it writes cannot disagree with what the create will accept. A DEFAULTED internal that cannot resolve falls back to public rather than failing a request that never asked for internal. The oblienGH-631 regression test re-implemented `isNetworkUrl` inside its own `vi.mock` factory, so it never ran the shipped predicate; it now spreads the real module and overrides only `toInternalUrl`. A test against the real minio catalog entry pins the port and scheme end to end, because the wrong endpoint is silent when it happens.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #631
Summary
Connecting an app (Supabase, MinIO, Redis, Meilisearch) into another project in
internalmode failed for every non-URL output — JWT secrets (SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY), passwords (POSTGRES_PASSWORD), tokens, usernames,bucket names — because
createConnectionrantoInternalUrlon every non-synthesizedtemplate output. Those values fail
new URL(value), sotoInternalUrlreturnednulland the request was rejected with "Internal mode isn't available for this connection."
A value with no host has nothing to rewrite. Those now inject verbatim in both modes.
Why there are two commits
Removing that rejection is correct, but three things downstream were reading it as a
signal, so the one-line gate on its own would have shipped a worse bug than it fixed.
1. The object-storage bind silently flipped from public to internal.
writeBindingchose internal-vs-public by catching theinternal mode isn't availableValidationError. The first output it links is always a credential (
buildObjectStorageEnvpushes the access key first), so that throw fired on every non-cloud MinIO bind and every
mode-less bind deterministically landed on public. The dashboard sends no mode
(
StorageSettings.tsx), so that is the default path. Once credentials stopped beingrejected, nothing threw and the bind committed to internal with the endpoint rewrite and
reachability never checked. It now asks
internalModeAvailable()about the endpointoutput — the only one of the three that carries a host.
2. A routed MinIO resolved to the console port, over TLS.
toInternalUrlfell back to the service's first declared endpoint when the resolvedvalue had no port, and
minio.jsondeclares the console (9001) before the S3 API (9000).A MinIO with a route on its S3 port therefore produced
S3_ENDPOINT=https://minio:9001—wrong port, and HTTPS against a plaintext listener. Every upload fails while the bind
reports success and the Storage tab keeps showing the working public URL, so nothing
surfaces it. Only the output's own source names the container port
(
publicUrl:minio:9000), sogetOutputPort()supplies it, and it outranks the resolvedURL's port — which is either absent (a routed
https://<host>) or the host side of apublished mapping (
19000of19000:9000).3. TLS followed the rewrite onto the container port.
East-west traffic terminates at the container, which serves plaintext; the certificate
lives on the edge. An endpoint declared
kind: "http"now drops tohttp:, so Kongresolves to
http://kong:8000as well. A DSN keeps its own scheme (postgresql:,mongodb:,redis:).4. Internal was never checked for co-location.
openship-<slug>networks are per-host andattachLinkedNetworksonly warns when theattach fails, so a cross-server internal link injected an alias that resolves nowhere and
still deployed green. Both ends must now derive to the same server, and the cloud check
reads the durable
cloudWorkspaceIdviaderiveProjectDeployTargetrather than theper-deployment
metasnapshot, which is absent on a project bound to cloud that hasn'tredeployed since. Credentials are exempt — a JWT carries no host, so gating one on
network topology would re-break exactly what #631 reported.
Changes
packages/core/src/app-templates.ts— newgetOutputPort()besidegetOutputService(): the container port an output's source declares.project-connection.util.ts—isNetworkUrl();toInternalUrl()takes adeclaredPortthat outranks the URL's own, and dropshttps:for anhttpendpoint.project-connection.service.ts—loadConnectionEnds()+resolveInternalValue()as the single resolver, exported as
internalModeAvailable()so a caller that mustchoose a mode before it writes cannot disagree with what the create accepts. Adds the
co-location gate. A defaulted internal that can't resolve falls back to public
instead of failing a request that never asked for internal.
project-storage.service.ts— mode comes from the predicate, not from a caughterror message.
Tests
isNetworkUrl, declared-port endpoint selection,https:→http:for http endpointsand scheme preservation for DSNs,
getOutputPort.the shipped output stops declaring its port, and a wrong endpoint is silent.
cross-server credential still injecting verbatim.
it resolves, all three public when it doesn't, and obeys an explicit mode without asking.
isNetworkUrlinside its ownvi.mockfactory,so it never ran the shipped predicate. It now spreads the real module and overrides only
toInternalUrl.Full suite green locally (
bun run test, 7/7 packages);tsc --noEmitclean forapps/api,packages/core, andapps/dashboard.