Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions apps/portal/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ export interface DevicePairing {
attested?: boolean;
fingerprint?: string | null;
app_origin?: string;
app_platform?: "native";
return_url?: string | null;
timer_declaration?: {digest:string};
timer_consent?: boolean | null;
Expand Down Expand Up @@ -132,7 +131,6 @@ export interface PendingAuthorization {
application_id: string;
application_name: string;
distribution: "web" | "portable";
platform?: "native";
application_origin?: string;
homepage: string;
project_url: string | null;
Expand Down
2 changes: 1 addition & 1 deletion apps/portal/src/authority-workflows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ export function Pairing({ pairingId }: { pairingId: string }) {
) : (
<>
<RequestIdentity request={{ application_name: pairing.connector_name, application_origin: pairing.app_origin,
platform: pairing.app_platform, homepage: pairing.app_origin ?? "", expires_at: pairing.expires_at }} showClientKey={false} />
homepage: pairing.app_origin ?? "", expires_at: pairing.expires_at }} showClientKey={false} />
{installationAccountLabel(pairing) ? (
<p className="installation-account-label"><Trans>Account: {literal(installationAccountLabel(pairing))}</Trans></p>
) : (
Expand Down
19 changes: 11 additions & 8 deletions apps/portal/src/authorization-view.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { StatusGlyph } from "@mdbase/connect-ui/status-glyph";
import { isNativeLoopbackOrigin } from "@mdbase-dev/connect-protocol/manifest";
import { isLoopbackHttpOrigin } from "@mdbase-dev/connect-protocol/manifest";
import { t, plural } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useLocaleRevision, i18n } from "@mdbase-dev/ui/i18n";
Expand Down Expand Up @@ -354,22 +354,25 @@ function appOriginKind(origin: string | undefined | null): string | null {
return null;
}

export function RequestIdentity({ request, showClientKey = true }: { request: Pick<PendingAuthorization, "application_name" | "homepage"> & Partial<Pick<PendingAuthorization, "distribution" | "platform" | "application_origin" | "icon" | "project_url" | "user_code" | "client_fingerprint" | "expires_at">>; showClientKey?: boolean }) {
export function RequestIdentity({ request, showClientKey = true }: { request: Pick<PendingAuthorization, "application_name" | "homepage"> & Partial<Pick<PendingAuthorization, "distribution" | "application_origin" | "icon" | "project_url" | "user_code" | "client_fingerprint" | "expires_at">>; showClientKey?: boolean }) {
useLocaleRevision();
const [failedIcon, setFailedIcon] = useState<string | null>(null);
const portable = request.distribution === "portable", native = request.platform === "native" && isNativeLoopbackOrigin(request.application_origin);
// A loopback HTTP origin is a program on this computer (a native app, or a web
// app under development). Its manifest names no verified publisher, so its
// name and icon are never shown as branding.
const portable = request.distribution === "portable", local = isLoopbackHttpOrigin(request.application_origin);
const originKind = appOriginKind(request.application_origin);
return (
<header className="request-identity">
<span className="request-identity-mark" aria-hidden="true">{!native && request.icon && request.icon !== failedIcon
<span className="request-identity-mark" aria-hidden="true">{!local && request.icon && request.icon !== failedIcon
? <img src={request.icon} alt="" referrerPolicy="no-referrer" onError={() => setFailedIcon(request.icon ?? null)} />
: native ? null : initials(request.application_name)}</span>
: local ? null : initials(request.application_name)}</span>
<div>
<h1>{native ? t(i18n)`Connect an app` : request.application_name}</h1>
<p className="request-origin">{native ? t(i18n)`an app running on this computer` : portable
<h1>{local ? t(i18n)`Connect an app` : request.application_name}</h1>
<p className="request-origin">{local ? t(i18n)`an app running on this computer` : portable
? <>{t(i18n)({ message: "App using a device code" })}{request.project_url && <> · <code>{host(request.project_url)}</code></>}</>
: request.application_origin?.trim() ? <><code>{request.application_origin}</code>{originKind && ` · ${originKind}`}</> : null}</p>
{native
{local
? <p className="request-guidance" role="note"><Trans>App identity unverified. This app calls itself {literal(request.application_name)}. Continue only if you started this connection intentionally.</Trans></p>
: portable
? <p className="request-guidance portable-authorization-warning" role="note"><Trans><strong>App origin unverified.</strong> Continue only if you started this connection intentionally{request.user_code ? <Trans> and it shows <code>{request.user_code}</code></Trans> : null}.{request.project_url ? <Trans> {literal(host(request.project_url))} does not verify its origin.</Trans> : null}</Trans></p>
Expand Down
32 changes: 32 additions & 0 deletions apps/portal/src/local-app-consent.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {render,screen} from "./test/render";
import {expect,it} from "vitest";
import {RequestIdentity} from "./authorization-view";
import type {PendingAuthorization} from "./api";
it.each(["Trusted Brand", "<0/>", "<b>x</b>", String.raw`name\path`])("describes local app %s as unverified without supplied branding",application_name=>{
const request={distribution:"web",application_name,homepage:"http://127.0.0.1:49152",application_origin:"http://127.0.0.1:49152",icon:"http://127.0.0.1:49152/logo.png",expires_at:new Date(Date.now()+60000).toISOString()} as PendingAuthorization;
const {container}=render(<RequestIdentity request={request}/>);
expect(screen.getByRole("heading",{name:"Connect an app"})).toBeInTheDocument();
expect(screen.getByText("an app running on this computer")).toBeInTheDocument();
expect(screen.getByRole("note")).toHaveTextContent(`App identity unverified. This app calls itself ${application_name}.`);
expect(container.querySelector("img")).toBeNull();
expect(screen.queryByText(/recognize this exact site/)).not.toBeInTheDocument();
});

it.each(["http://localhost:5173", "http://[::1]:5173", "http://127.0.0.1"])("gives an app under development at %s the same local treatment",application_origin=>{
const request={distribution:"web",application_name:"Worklog",homepage:application_origin,application_origin,icon:`${application_origin}/icon.png`,expires_at:new Date(Date.now()+60000).toISOString()} as PendingAuthorization;
const {container}=render(<RequestIdentity request={request}/>);
expect(screen.getByRole("heading",{name:"Connect an app"})).toBeInTheDocument();
expect(screen.getByText("an app running on this computer")).toBeInTheDocument();
expect(screen.getByRole("note")).toHaveTextContent("App identity unverified. This app calls itself Worklog.");
expect(container.querySelector("img")).toBeNull();
});

it.each(["https://remote.example.test", "https://localhost:5173", "http://localhost.example.test", "http://127.0.0.2:5173", "http://localhost:5173/path", "null", undefined])("shows web consent and the actual origin for %s",application_origin=>{
const request={distribution:"web",application_name:"Claimed Local",homepage:"http://127.0.0.1:49152",application_origin,expires_at:new Date(Date.now()+60000).toISOString()} as PendingAuthorization;
render(<RequestIdentity request={request}/>);
expect(screen.queryByText("an app running on this computer")).not.toBeInTheDocument();
if (application_origin) {
expect(screen.getByText(/Continue only if you started this and recognize this site\./)).toBeInTheDocument();
expect(screen.getByText(application_origin)).toBeInTheDocument();
} else expect(screen.getByText(/No requesting origin is available\./)).toBeInTheDocument();
});
23 changes: 0 additions & 23 deletions apps/portal/src/native-platform-consent.test.tsx

This file was deleted.

8 changes: 4 additions & 4 deletions apps/portal/src/pairing-picker-consent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,13 @@ describe("one explicit collection consent", () => {
expect(receipt.queryByText("Allow Reader to create new collections")).not.toBeInTheDocument();
expect(view.posts()).toEqual([]);
});
it("describes native pairing as an unverified app running on this computer", async () => {
const f = await fixture({app_platform:"native",app_origin:"http://127.0.0.1:49152"});
it.each(["http://127.0.0.1:49152", "http://localhost:5173", "http://[::1]:5173"])("describes pairing from %s as an unverified app running on this computer", async app_origin => {
const f = await fixture({app_origin});
expect(screen.getByRole("note")).toHaveTextContent("App identity unverified. This app calls itself Reader.");
expect(f.posts()).toEqual([]);
});
it.each(["https://remote.example.test", "http://localhost:49152", "http://[::1]:49152", undefined])("shows the actual pairing origin instead of native wording for %s", async app_origin => {
await fixture({app_platform:"native",app_origin});
it.each(["https://remote.example.test", "http://127.0.0.2:49152", undefined])("shows the actual pairing origin instead of local wording for %s", async app_origin => {
await fixture({app_origin});
expect(screen.queryByText(/Requested by an app running on this computer/)).not.toBeInTheDocument();
if (app_origin) expect(document.querySelector(".request-origin code")).toHaveTextContent(app_origin);
});
Expand Down
4 changes: 4 additions & 0 deletions changelog.d/localhost-app-development-removed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## Removed

- `MDBASE_CONNECT_ALLOW_INSECURE_MANIFESTS` is no longer read by the server; remove it from deployment configuration.
- SDK storage entries no longer accept `allowLoopbackHttp` or `allowNativeLoopbackHttp`, and `selectAppEnvironment` and `packagedApplicationAssets` no longer take a platform argument.
3 changes: 3 additions & 0 deletions changelog.d/localhost-app-development.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## Added

- Develop an app on `http://localhost` against production or staging: the SDK accepts a loopback HTTP origin for the app in every environment, Connect registers and pairs a manifest whose homepage is that origin, and the approval screen describes it as an app running on this computer with an unverified identity. Connect's own origins stay HTTPS.
3 changes: 3 additions & 0 deletions changelog.d/sdk-dev-server-module-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## Fixed

- The SDK installed from npm now starts under `vite dev`: a development server's version query on the SDK's module URL (`?v=…`) no longer makes it refuse its packaged files with "The app couldn't load".
1 change: 0 additions & 1 deletion deploy/self-host/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ services:
MDBASE_CONNECT_HOSTED_PROVIDER_URL: ${HOSTED_PROVIDER_PUBLIC_URL:-}
MDBASE_CONNECT_HOSTED_PROVIDER_INTERNAL_TOKEN: ${HOSTED_PROVIDER_INTERNAL_TOKEN:-}
MDBASE_CONNECT_TRUST_PROXY: "1"
MDBASE_CONNECT_ALLOW_INSECURE_MANIFESTS: "0"
MDBASE_CONNECT_RELAY_NATS_URL: nats://relay-broker:4222
MDBASE_CONNECT_RELAY_NATS_TOKEN: ${RELAY_NATS_TOKEN}
# Self-hosting runs only the legacy backend; the next backend's services
Expand Down
1 change: 0 additions & 1 deletion deploy/tailnet/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ services:
DATABASE_URL: postgres://mdbase:${POSTGRES_PASSWORD}@127.0.0.1:5432/mdbase_connect
MDBASE_CONNECT_DEV_AUTH: "0"
MDBASE_CONNECT_TAILSCALE_AUTH: "1"
MDBASE_CONNECT_ALLOW_INSECURE_MANIFESTS: "1"
# This deployment runs only the legacy backend; the server's default is next.
MDBASE_NEW_ACCOUNT_BACKEND: legacy
network_mode: host
Expand Down
1 change: 0 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,6 @@ services:
MDBASE_CONNECT_HOSTED_PROVIDER_INTERNAL_TOKEN: ${MDBASE_CONNECT_HOSTED_PROVIDER_INTERNAL_TOKEN:-}
MDBASE_CONNECT_ALLOW_INSECURE_HOSTED_PROVIDER: ${MDBASE_CONNECT_ALLOW_INSECURE_HOSTED_PROVIDER:-0}
MDBASE_CONNECT_TRUST_PROXY: ${MDBASE_CONNECT_TRUST_PROXY:-0}
MDBASE_CONNECT_ALLOW_INSECURE_MANIFESTS: ${MDBASE_CONNECT_ALLOW_INSECURE_MANIFESTS:-0}
MDBASE_CONNECT_RELAY_NATS_URL: nats://relay-broker:4222
MDBASE_CONNECT_RELAY_NATS_TOKEN: ${MDBASE_CONNECT_RELAY_NATS_TOKEN:-replace-this-relay-token-with-at-least-32-characters}
# This stack runs no next control plane, so it creates legacy accounts.
Expand Down
10 changes: 9 additions & 1 deletion docs/application-installation-pairing.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ registered `homepage` and `redirect_uris`. Opaque redirect origins (`null`) neve
authorize pairing. The approval screen shows the registered name and the exact
requested origin, so the user can check where the request came from.

An app under development declares a loopback HTTP origin (`localhost`,
`127.0.0.1` or `[::1]`, any port) as its `homepage`, and pairs from exactly that
origin as an app runtime in every environment, production included. The
loopback declaration is its own registered application; see
[local app development](local-app-development.md).

For additional native or loopback locations, an app may include this optional
field in its manifest (both supported contract versions):

Expand All @@ -23,7 +29,9 @@ field in its manifest (both supported contract versions):
```

Additional origins use only the configured deployment environment and requested
device kind. They must be exact canonical HTTPS origins, loopback HTTP origins,
device kind. They must be exact canonical HTTPS origins, loopback HTTP origins
(`lab` only; staging and production never pair a loopback origin that is merely
listed here),
mobile `capacitor://` origins, or app-runtime Chrome extension origins
(`chrome-extension://` followed by the 32-letter extension ID): no wildcards,
paths, credentials, query or fragment. Other browser extension schemes are not
Expand Down
74 changes: 74 additions & 0 deletions docs/local-app-development.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Local app development

An application is developed on a loopback HTTP origin against the same Connect
environment it will ship to. There is no development flag, allowlist entry or
separate SDK package for it.

## The rule

A loopback HTTP web origin (`http://localhost:<port>`, `http://127.0.0.1:<port>`
or `http://[::1]:<port>`) is an admissible **application** origin in every
environment: LAB, staging and production. Connect's own origins (control plane,
log, portal) stay HTTPS outside LAB and isolated local builds. No other
plain-HTTP origin is an application origin anywhere.

| Check | Where | Loopback application origin |
|---|---|---|
| Declaration `origin`, storage, asset and return-URL checks | SDK (`app-origin.ts`, `return-url.ts`) | Accepted in every environment |
| Control-plane and log origins | SDK (`selectAppEnvironment`, `serviceLoopbackAllowed`) | HTTPS only outside LAB/local builds |
| Manifest `homepage` and `redirect_uris` | `POST /v1/apps/register`, `POST /v1/apps/validate` | Accepted |
| Installation pairing START | `installationApp` | Accepted when the origin is the one the manifest's homepage or redirect URIs declare, for an app runtime |
| Loopback origin listed only under `installation_origins` | `installationApp` | Refused in staging and production, as before |
| Approval screen | portal `RequestIdentity` | "an app running on this computer", identity unverified, no icon |

`@mdbase-dev/connect-dev validate-manifest` stays strict by default so a
production manifest with a loopback homepage is caught before it ships; pass
`--allow-local` for the development manifest.

## What a developer does

Keep two manifests: the published one with the HTTPS homepage, and a
development one whose `homepage` and `redirect_uris` use the dev server's
origin. Pass the matching one to `createMdbase`, with `environment:
"production"` in both cases. The two are separate registered applications
(different canonical identity, the same family identity when they share an ID),
so collection approvals and device storage made on localhost stay on localhost.

## Development servers

A development server serves the SDK's modules from `node_modules` with a
cache-busting query (`vite dev` adds `?v=<hash>`). The SDK used to refuse to
select its packaged WebAssembly files from a module URL with a query, which
surfaced as "The app couldn't load" (`application_packaged_assets_unavailable`)
for every app that installed the SDK from npm; first-party apps link the
workspace package and never met it. The module URL's query is now ignored: the
files resolve beside the module without it, on the app's origin, and their bytes
are still checked against the packaged pins. `vite dev` and `vite build` both
work with Vite's default configuration.

## Why this is safe

- A remote page cannot give itself a loopback origin. The browser sets `Origin`,
and requesting or framing a loopback URL does not transfer that origin or its
storage. The party that can serve a loopback origin is a program already
running on the person's computer.
- Production already accepted `http://127.0.0.1:<port>` for manifests that
declare `platform: "native"` (see [native app platform](native-app-platform.md)).
This extends the same admission to `localhost` and `[::1]` and to web
declarations, and requires the origin to be the one the manifest declares.
- Registration was already open to any HTTPS origin and identifies no
publisher. A loopback declaration gains nothing an HTTPS lookalike could not
already register, and unlike one it cannot be reached by a link.
- The approval screen never shows a loopback application's name or icon as
branding. Account approval, collection consent and per-collection capability
grants are unchanged, and each grant is bound to the exact origin and port.
- Account-origin cookie privileges and CORS credentials are decided by the
configured account origins and are unaffected.

## Removed

`MDBASE_CONNECT_ALLOW_INSECURE_MANIFESTS` no longer exists; the server ignores
it. The SDK's storage modules no longer take `allowLoopbackHttp` or
`allowNativeLoopbackHttp`, and `selectAppEnvironment` no longer takes
`platform`: an application origin is admitted by one rule. `allowLoopbackHttp`
remains only where it relaxes HTTPS for Connect's own origins in LAB.
1 change: 0 additions & 1 deletion docs/mcp-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@ fetch the other's development manifest:

```bash
MDBASE_CONNECT_DEV_AUTH=1 \
MDBASE_CONNECT_ALLOW_INSECURE_MANIFESTS=1 \
DATABASE_URL=memory \
PUBLIC_URL=http://127.0.0.1:8787 \
pnpm --filter @mdbase/connect-server dev
Expand Down
Loading
Loading