Skip to content

fix(connections): allow non-URL outputs in internal connection mode - #632

Merged
Hydralerne merged 2 commits into
oblien:mainfrom
chbndrhnns:fix/internal-connection-non-url-outputs
Aug 19, 2026
Merged

fix(connections): allow non-URL outputs in internal connection mode#632
Hydralerne merged 2 commits into
oblien:mainfrom
chbndrhnns:fix/internal-connection-non-url-outputs

Conversation

@chbndrhnns

@chbndrhnns chbndrhnns commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Closes #631

Summary

Connecting an app (Supabase, MinIO, Redis, Meilisearch) into another project in
internal mode failed for every non-URL output — JWT secrets (SUPABASE_ANON_KEY,
SUPABASE_SERVICE_ROLE_KEY), passwords (POSTGRES_PASSWORD), tokens, usernames,
bucket names — because createConnection ran toInternalUrl on every non-synthesized
template output. Those values fail new URL(value), so toInternalUrl returned null
and 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.
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. The dashboard sends no mode
(StorageSettings.tsx), so that is the default path. Once credentials stopped being
rejected, nothing threw and the bind committed to internal with the endpoint rewrite and
reachability never checked. It now asks internalModeAvailable() about the endpoint
output — the only one of the three that carries a host.

2. A routed MinIO resolved to the console port, over TLS.
toInternalUrl fell back to the service's first declared endpoint when the resolved
value had no port, and minio.json declares 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), 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).

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 to http:, so Kong
resolves to http://kong:8000 as well. A DSN keeps its own scheme (postgresql:,
mongodb:, redis:).

4. 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 rather than the
per-deployment meta snapshot, which is absent on a project bound to cloud that hasn't
redeployed 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 — new getOutputPort() beside
    getOutputService(): the container port an output's source declares.
  • project-connection.util.tsisNetworkUrl(); toInternalUrl() takes a
    declaredPort that outranks the URL's own, and drops https: for an http endpoint.
  • project-connection.service.tsloadConnectionEnds() + resolveInternalValue()
    as the single resolver, exported as internalModeAvailable() so a caller that must
    choose 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 caught
    error message.

Tests

  • isNetworkUrl, declared-port endpoint selection, https:http: for http endpoints
    and scheme preservation for DSNs, getOutputPort.
  • A guard against the real minio catalog entry — the port and scheme fix is inert if
    the shipped output stops declaring its port, and a wrong endpoint is silent.
  • Cross-server refusal, same-server allow, defaulted-mode fallback to public, and a
    cross-server credential still injecting verbatim.
  • Storage bind: asks about the endpoint (not a credential), wires all three internal when
    it resolves, all three public when it doesn't, and obeys an explicit mode without asking.
  • The fix(connections): internal connection mode rejects non-URL outputs (JWT keys, passwords, tokens) #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.

Full suite green locally (bun run test, 7/7 packages); tsc --noEmit clean for
apps/api, packages/core, and apps/dashboard.

chbndrhnns and others added 2 commits August 18, 2026 21:38
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.
@Hydralerne
Hydralerne merged commit 9ae29d7 into oblien:main Aug 19, 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.

fix(connections): internal connection mode rejects non-URL outputs (JWT keys, passwords, tokens)

2 participants