) : (
diff --git a/apps/portal/src/authorization-view.tsx b/apps/portal/src/authorization-view.tsx
index 3c652e020..838ed85c4 100644
--- a/apps/portal/src/authorization-view.tsx
+++ b/apps/portal/src/authorization-view.tsx
@@ -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";
@@ -354,22 +354,25 @@ function appOriginKind(origin: string | undefined | null): string | null {
return null;
}
-export function RequestIdentity({ request, showClientKey = true }: { request: Pick & Partial>; showClientKey?: boolean }) {
+export function RequestIdentity({ request, showClientKey = true }: { request: Pick & Partial>; showClientKey?: boolean }) {
useLocaleRevision();
const [failedIcon, setFailedIcon] = useState(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 (
- {!native && request.icon && request.icon !== failedIcon
+ {!local && request.icon && request.icon !== failedIcon
? setFailedIcon(request.icon ?? null)} />
- : native ? null : initials(request.application_name)}
+ : local ? null : initials(request.application_name)}
-
{native ? t(i18n)`Connect an app` : request.application_name}
-
{native ? t(i18n)`an app running on this computer` : portable
+
{local ? t(i18n)`Connect an app` : request.application_name}
+
{local ? t(i18n)`an app running on this computer` : portable
? <>{t(i18n)({ message: "App using a device code" })}{request.project_url && <> · {host(request.project_url)}>}>
: request.application_origin?.trim() ? <>{request.application_origin}{originKind && ` · ${originKind}`}> : null}
- {native
+ {local
?
App identity unverified. This app calls itself {literal(request.application_name)}. Continue only if you started this connection intentionally.
: portable
?
App origin unverified. Continue only if you started this connection intentionally{request.user_code ? and it shows {request.user_code} : null}.{request.project_url ? {literal(host(request.project_url))} does not verify its origin. : null}
diff --git a/apps/portal/src/local-app-consent.test.tsx b/apps/portal/src/local-app-consent.test.tsx
new file mode 100644
index 000000000..08e06cde6
--- /dev/null
+++ b/apps/portal/src/local-app-consent.test.tsx
@@ -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/>", "x", 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();
+ 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();
+ 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();
+ 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();
+});
diff --git a/apps/portal/src/native-platform-consent.test.tsx b/apps/portal/src/native-platform-consent.test.tsx
deleted file mode 100644
index 814e0e511..000000000
--- a/apps/portal/src/native-platform-consent.test.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-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/>", "x", String.raw`name\path`])("describes native app %s as local and unverified without supplied branding",application_name=>{
- const request={platform:"native",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();
- 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(["https://remote.example.test", "http://localhost:49152", "http://[::1]:49152", "null", undefined])("shows web consent and the actual origin for a native claim from %s",application_origin=>{
- const request={platform:"native",distribution:"web",application_name:"Claimed Native",homepage:"http://127.0.0.1:49152",application_origin,expires_at:new Date(Date.now()+60000).toISOString()} as PendingAuthorization;
- render();
- 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();
-});
diff --git a/apps/portal/src/pairing-picker-consent.test.tsx b/apps/portal/src/pairing-picker-consent.test.tsx
index 0d0f7c404..f3b75e1de 100644
--- a/apps/portal/src/pairing-picker-consent.test.tsx
+++ b/apps/portal/src/pairing-picker-consent.test.tsx
@@ -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);
});
diff --git a/changelog.d/localhost-app-development-removed.md b/changelog.d/localhost-app-development-removed.md
new file mode 100644
index 000000000..df17bd8b4
--- /dev/null
+++ b/changelog.d/localhost-app-development-removed.md
@@ -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.
diff --git a/changelog.d/localhost-app-development.md b/changelog.d/localhost-app-development.md
new file mode 100644
index 000000000..6ba03d5d4
--- /dev/null
+++ b/changelog.d/localhost-app-development.md
@@ -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.
diff --git a/changelog.d/sdk-dev-server-module-url.md b/changelog.d/sdk-dev-server-module-url.md
new file mode 100644
index 000000000..66f9f6d13
--- /dev/null
+++ b/changelog.d/sdk-dev-server-module-url.md
@@ -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".
diff --git a/deploy/self-host/compose.yml b/deploy/self-host/compose.yml
index cd4a68afe..ae32fa7a3 100644
--- a/deploy/self-host/compose.yml
+++ b/deploy/self-host/compose.yml
@@ -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
diff --git a/deploy/tailnet/compose.yml b/deploy/tailnet/compose.yml
index 325ead0cc..46477b860 100644
--- a/deploy/tailnet/compose.yml
+++ b/deploy/tailnet/compose.yml
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
index 8073d9e1c..63a374405 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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.
diff --git a/docs/application-installation-pairing.md b/docs/application-installation-pairing.md
index 55aa7f123..8e26cfd34 100644
--- a/docs/application-installation-pairing.md
+++ b/docs/application-installation-pairing.md
@@ -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):
@@ -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
diff --git a/docs/local-app-development.md b/docs/local-app-development.md
new file mode 100644
index 000000000..f69b6d88c
--- /dev/null
+++ b/docs/local-app-development.md
@@ -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:`, `http://127.0.0.1:`
+or `http://[::1]:`) 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=`). 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:` 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.
diff --git a/docs/mcp-gateway.md b/docs/mcp-gateway.md
index 4b7c0e6a3..cf0947022 100644
--- a/docs/mcp-gateway.md
+++ b/docs/mcp-gateway.md
@@ -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
diff --git a/docs/native-app-platform.md b/docs/native-app-platform.md
index 59b0e3ece..44d0cfdc9 100644
--- a/docs/native-app-platform.md
+++ b/docs/native-app-platform.md
@@ -14,7 +14,8 @@ For a declared native application, production accepts canonical origins
exception applies to SDK origin checks, manifest homepage/redirect validation,
and app-runtime installation admission. `localhost`, subdomains, other IPs,
IPv6, credentials, and opaque/custom origins receive no native exception.
-Existing LAB/local development behavior stays separate. An origin and port are
+A web declaration may since use any loopback HTTP origin it declares as its
+homepage; see [local app development](local-app-development.md). An origin and port are
still bound exactly to the installation, storage, pairing and resulting grant;
the platform declaration does not make credentials portable between origins.
Redirects stay exact manifest entries (including path/query/port); any port may
@@ -28,8 +29,9 @@ control-plane application-origin admission. Allowed methods/headers, bearer
authentication, nonce signatures and replay checks continue to apply.
No deployment/configuration change is part of this work.
-Native consent is shown only when the recorded request origin is loopback;
-otherwise web consent shows the requesting origin. Native consent says **"an
+This consent is shown whenever the recorded request origin is loopback HTTP,
+for native and web declarations alike; otherwise web consent shows the
+requesting origin. Native consent says **"an
app running on this computer"** and explicitly says its identity is unverified.
Do not display a manifest-supplied icon or present the application
name/homepage as verified branding. The user must have started the connection
diff --git a/docs/testing-environment.md b/docs/testing-environment.md
index 247e81174..b877bf09a 100644
--- a/docs/testing-environment.md
+++ b/docs/testing-environment.md
@@ -197,10 +197,7 @@ adjacent Connect checkout:
import { startConnectTestEnvironment } from
"../mdbase-connect/scripts/lib/connect-test-environment.mjs";
-const connect = await startConnectTestEnvironment({
- // Accept a development manifest served by the application under test.
- allowLocalApps: true
-});
+const connect = await startConnectTestEnvironment();
try {
// Start the application with connect.serverUrl, then drive its real
// authorization and collection workflow.
@@ -219,7 +216,6 @@ A consumer that needs hosted collections can supply a disposable provider:
```js
const connect = await startConnectTestEnvironment({
- allowLocalApps: true,
hostedProvider: {
// Reachable from the Connect container.
url: `http://host.docker.internal:${provider.port}`,
diff --git a/packages/protocol/src/manifest.ts b/packages/protocol/src/manifest.ts
index cd76d342b..f0250bdea 100644
--- a/packages/protocol/src/manifest.ts
+++ b/packages/protocol/src/manifest.ts
@@ -879,6 +879,18 @@ function isNativeLoopbackUrl(value: string): boolean {
return isNativeLoopbackOrigin(origin);
}
+/** An exact loopback HTTP web origin (`localhost`, `127.0.0.1` or `[::1]`, any
+ * port): where an application runs while it is being developed. A remote page
+ * cannot give itself this origin, but any program on the computer can serve it,
+ * so it identifies no publisher. */
+export function isLoopbackHttpOrigin(value: unknown): value is string {
+ if (typeof value !== "string") return false;
+ try {
+ const url = new URL(value);
+ return url.protocol === "http:" && isLoopback(url.hostname) && url.origin === value;
+ } catch { return false; }
+}
+
function secureOrAllowedLocal(url: URL, allowLocal: boolean): boolean {
return url.protocol === "https:"
|| (allowLocal && url.protocol === "http:" && isLoopback(url.hostname));
diff --git a/runtime/packages/sdk/README.md b/runtime/packages/sdk/README.md
index 7002f61dd..e7819b448 100644
--- a/runtime/packages/sdk/README.md
+++ b/runtime/packages/sdk/README.md
@@ -383,9 +383,31 @@ trust handoff for that exact environment; changing an app build variable cannot
retarget a LAB archive. Staging packaging pins Connect to
`https://connect-staging.mdbase.dev`, production to `https://connect.mdbase.dev`.
CP/log origins and release trust still come only from the verified build context;
-missing, expired or crossed trust refuses without a fallback. Staging and
-production do not permit HTTP loopback app origins. Isolated `local` builds remain
-separate and refuse all managed environments.
+missing, expired or crossed trust refuses without a fallback. Isolated `local`
+builds remain separate and refuse all managed environments.
+
+### Developing on localhost
+
+In every managed environment the app's own `origin` may be a loopback HTTP
+origin (`http://localhost:`, `http://127.0.0.1:` or
+`http://[::1]:`), so an app is developed against production with the
+published package:
+
+```ts
+createMdbase({ id, name, origin: "http://localhost:5173", environment: "production", manifest: devManifest });
+```
+
+The manifest's `homepage` and `redirect_uris` use that same origin. Connect
+registers it as its own application, apart from the one published at the HTTPS
+origin: approvals and device storage belong to the exact origin and are not
+shared between the two. The approval screen calls it "an app running on this
+computer" and says its identity is unverified, as it does for a native app.
+Connect's own origins (control plane, log) stay HTTPS outside LAB; no other
+plain-HTTP app origin is accepted.
+
+`vite dev` and `vite build` both work with Vite's default configuration: the
+SDK's worker and WebAssembly files are served from `node_modules` in
+development and emitted as assets in a build.
The app manifest must declare the installation origin under
`installation_origins.staging` for staging pairing, just as for other environments.
diff --git a/runtime/packages/sdk/public-api.txt b/runtime/packages/sdk/public-api.txt
index e14ef2ba0..561d18ee4 100644
--- a/runtime/packages/sdk/public-api.txt
+++ b/runtime/packages/sdk/public-api.txt
@@ -2335,7 +2335,6 @@ AppDeviceRegistrationReceipt@app-host/wasm-runtime.ts
AppEnvironmentSelection@app-host/app-environment.ts
readonly allowLoopbackHttp: boolean
- readonly allowNativeLoopbackHttp: boolean
readonly appOrigin: string
readonly assetSha256: string
readonly cpOrigin: string
@@ -2451,8 +2450,6 @@ AppHttpLogTransport@app-host/http-log.ts
static new (authority: AppLogHttpAuthority, options?: AppLogHttpOptions): AppHttpLogTransport
AppIndexedDbDeviceKeyOptions@app-host/device-key-idb.ts
- readonly allowLoopbackHttp?: boolean | undefined
- readonly allowNativeLoopbackHttp?: boolean | undefined
readonly mode: 'fresh' | 'existing'
readonly origin: string
readonly signal: AbortSignal
@@ -2472,16 +2469,12 @@ AppIndexedDbDeviceKeyProvider@app-host/device-key-provider.ts
static open: (options: AppIndexedDbDeviceKeyProviderOptions) => Promise
AppIndexedDbDeviceKeyProviderOptions@app-host/device-key-provider.ts
- readonly allowLoopbackHttp?: boolean | undefined
- readonly allowNativeLoopbackHttp?: boolean | undefined
readonly mode: 'fresh' | 'existing'
readonly origin: string
readonly signal: AbortSignal
readonly source: AppDeviceKeyCustodyPin
AppIndexedDbNoiseOptions@app-host/noise-idb.ts
- allowLoopbackHttp?: boolean | undefined
- readonly allowNativeLoopbackHttp?: boolean | undefined
mode: 'fresh' | 'existing'
origin: string
signal: AbortSignal
@@ -2555,7 +2548,6 @@ AppInstallationSignInError@app-host/installation-sign-in.ts
AppInstallationSignInOptions@app-host/installation-sign-in.ts
readonly allowLoopbackHttp?: boolean | undefined
- readonly allowNativeLoopbackHttp?: boolean | undefined
readonly appId: string
readonly appName: string
readonly cpOrigin: string
@@ -2605,7 +2597,6 @@ AppInstallationStore@app-host/installation-store.ts
AppInstallationStoreOptions@app-host/installation-store.ts
readonly allowLoopbackHttp?: boolean | undefined
- readonly allowNativeLoopbackHttp?: boolean | undefined
readonly appId: string
readonly cpOrigin: string
readonly locks: AppLockPort | undefined
@@ -3073,7 +3064,7 @@ AppWebCloudCopyHost@app-host/cloud-copy-host.ts
adoptCloudCopy: (options: AppCloudCopyAdoptionOptions, sql: AppSqlLifetime) => void
attachDataFacade: (channel: MessagePort) => ReturnType['resourceSetup']
attestInstallation: () => Promise
- bootstrapCloudCopy: (options: { scope: Readonly; collectionCurrent(): boolean; purpose: 'create' | 'join'; outcomeMode: 'fresh' | 'existing'; signal: AbortSignal; reconcile?: 'explicit-unknown-outcome'; displayName?: string; fetch?: typeof fetch; allowLoopbackHttp?: boolean; allowNativeLoopbackHttp?: boolean; }) => Promise
+ bootstrapCloudCopy: (options: { scope: Readonly; collectionCurrent(): boolean; purpose: 'create' | 'join'; outcomeMode: 'fresh' | 'existing'; signal: AbortSignal; reconcile?: 'explicit-unknown-outcome'; displayName?: string; fetch?: typeof fetch; allowLoopbackHttp?: boolean; }) => Promise
close: () => Promise
completeInstallationSignIn: () => Promise
detachDataFacade: (channel: MessagePort) => void
@@ -3094,12 +3085,11 @@ AppWebCloudCopyHost@app-host/cloud-copy-host.ts
waitForVerifiedRead: (options: { signal: AbortSignal; timeoutMs?: number; onProgress?: (progress: import("./opening-progress.js").AppOpeningProgress) => void; cold?: boolean; }) => Promise
wakeCollectionLog: (options: { signal: AbortSignal; }) => Promise
static new (source: AppCloudCopyHostSession, installation: AppInstallationCustodyAuthority, originalRelease: AppCloudCopyHostRelease, origin: string, lifetime: AbortSignal): AppWebCloudCopyHost
- static openInstallationDevice: (options: { signIn: AppProtectedInstallationSignIn; installation: AppInstallationCustodyAuthority; release: AppCloudCopyHostRelease; origin: string; signal: AbortSignal; loadRuntime(options: { signal: AbortSignal; }): Promise; allowLoopbackHttp?: boolean; allowNativeLoopbackHttp?: boolean; }) => Promise
- static openOriginalDevice: (options: { session: AppCloudCopyHostSession; installation: AppInstallationCustodyAuthority; release: AppCloudCopyHostRelease; origin: string; mode: 'fresh' | 'existing'; signal: AbortSignal; loadRuntime(options: { signal: AbortSignal; }): Promise; fetch?: typeof fetch; allowLoopbackHttp?: boolean; allowNativeLoopbackHttp?: boolean; }) => Promise
+ static openInstallationDevice: (options: { signIn: AppProtectedInstallationSignIn; installation: AppInstallationCustodyAuthority; release: AppCloudCopyHostRelease; origin: string; signal: AbortSignal; loadRuntime(options: { signal: AbortSignal; }): Promise; allowLoopbackHttp?: boolean; }) => Promise
+ static openOriginalDevice: (options: { session: AppCloudCopyHostSession; installation: AppInstallationCustodyAuthority; release: AppCloudCopyHostRelease; origin: string; mode: 'fresh' | 'existing'; signal: AbortSignal; loadRuntime(options: { signal: AbortSignal; }): Promise; fetch?: typeof fetch; allowLoopbackHttp?: boolean; }) => Promise
AppWebCollectionAdapterOptions@app-host/web-collection.ts
readonly allowLoopbackHttp?: boolean | undefined
- readonly allowNativeLoopbackHttp?: boolean | undefined
readonly fetch?: { (input: RequestInfo | URL, init?: RequestInit): Promise; (input: string | URL | Request, init?: RequestInit): Promise; } | undefined
handoff: (value: AppWebCollectionHandoff) => void
readonly joinDatabaseName: string
@@ -3231,7 +3221,6 @@ AppWebSignInSession@app-host/web-sign-in-session.ts
AppWebSignInSessionOptions@app-host/web-sign-in-session.ts
readonly allowLoopbackHttp?: boolean | undefined
- readonly allowNativeLoopbackHttp?: boolean | undefined
readonly appId: string
readonly appName: string
readonly autoSelect?: 'only' | 'never' | undefined
@@ -6470,7 +6459,7 @@ sealBundle@account-key.ts
: (secret: Uint8Array, password: string, account: string, options?: SealOptions) => Promise
selectAppEnvironment@app-host/app-environment.ts
- : (input: { environment: 'lab' | 'staging' | 'production' | 'local'; appOrigin: string; platform?: 'native'; release: AppBundledReleaseTrust; }) => AppEnvironmentSelection
+ : (input: { environment: 'lab' | 'staging' | 'production' | 'local'; appOrigin: string; release: AppBundledReleaseTrust; }) => AppEnvironmentSelection
serveNoise@testing/noise-server.ts
: (carrier: MessageCarrier, backend: Connector, o: ServeNoiseOptions) => void
diff --git a/runtime/packages/sdk/scripts/app-cloud-copy-outcome-smoke.mjs b/runtime/packages/sdk/scripts/app-cloud-copy-outcome-smoke.mjs
index 0bc1bc134..0d1f5f197 100644
--- a/runtime/packages/sdk/scripts/app-cloud-copy-outcome-smoke.mjs
+++ b/runtime/packages/sdk/scripts/app-cloud-copy-outcome-smoke.mjs
@@ -31,8 +31,8 @@ self.onmessage=async({data})=>{try{
if(data.op==="stop"){current=false;ctrl?.abort();vault?.close();store?.close();provider?.close();self.postMessage({id:data.id,stopped:true});return;}
check(data.op==="init"&&!initialized&&data.owned===true);initialized=true;current=true;ctrl=new AbortController();const signal=ctrl.signal;
const p={accountId:data.scope.account,installationId:data.scope.installation,connectorId:"66666666-6666-6666-6666-666666666666",deviceId:"44444444-4444-4444-4444-444444444444",collection:data.scope.collection,purpose:"create",assetSha256:"aa".repeat(32),cpOrigin:"https://cp.example.test",logOrigin:"https://log.example.test",isCurrent:()=>current,installationOwned:()=>current};
- provider=await AppIndexedDbDeviceKeyProvider.open({source:p,origin:location.origin,mode:data.providerMode??data.mode,signal,allowLoopbackHttp:true});const key=provider.deviceCustodyKek({signal});check(!key.extractable);
- const opts={source:p,origin:location.origin,mode:data.mode,signal,allowLoopbackHttp:true};
+ provider=await AppIndexedDbDeviceKeyProvider.open({source:p,origin:location.origin,mode:data.providerMode??data.mode,signal});const key=provider.deviceCustodyKek({signal});check(!key.extractable);
+ const opts={source:p,origin:location.origin,mode:data.mode,signal};
if(data.case==="missing"){let refused=false;try{store=await AppIndexedDbCloudCopyOutcomeStore.open(opts);}catch{refused=true;}check(refused);self.postMessage({id:data.id,missingExistingRefused:true});return;}
const receipt={connectorId:p.connectorId,deviceId:p.deviceId,installationId:p.installationId,signPublicKey:new Uint8Array(32).fill(1),kemPublicKey:new Uint8Array(32).fill(2),noisePublicKey:new Uint8Array(32).fill(3)};
store=await AppIndexedDbCloudCopyOutcomeStore.open(opts);
diff --git a/runtime/packages/sdk/scripts/app-device-key-custody-smoke.mjs b/runtime/packages/sdk/scripts/app-device-key-custody-smoke.mjs
index 0c849cd33..36946c682 100644
--- a/runtime/packages/sdk/scripts/app-device-key-custody-smoke.mjs
+++ b/runtime/packages/sdk/scripts/app-device-key-custody-smoke.mjs
@@ -20,7 +20,7 @@ const source=scope=>({accountId:scope.account,connectorId:"66666666-6666-6666-66
const ns=p=>"mdbase.app-device-keys.v1:"+[p.accountId,p.connectorId,p.deviceId,p.installationId].map(v=>v.toLowerCase().replaceAll("-","")).join(":");
self.onmessage=async({data})=>{try{
if(data.op==="stop"){current=false;ctrl?.abort();await rt?.close();keys?.close();noise?.close();provider?.close();self.postMessage({id:data.id,stopped:true});return;}
- check(data.op==="init"&&!initialized&&data.owned===true);initialized=true;current=true;originalScope=data.scope;ctrl=new AbortController();const signal=ctrl.signal,p=source(data.scope),opts={source:p,origin:location.origin,allowLoopbackHttp:true,mode:data.mode,signal};
+ check(data.op==="init"&&!initialized&&data.owned===true);initialized=true;current=true;originalScope=data.scope;ctrl=new AbortController();const signal=ctrl.signal,p=source(data.scope),opts={source:p,origin:location.origin,mode:data.mode,signal};
if(data.case==="atomic"){
const q=source(data.scope),o={...opts,source:q,mode:"fresh"},a=await AppIndexedDbDeviceKeyProtectedStore.open(o),b=await AppIndexedDbDeviceKeyProtectedStore.open(o);
try{const result=await Promise.all([a.create(ns(q),Uint8Array.of(7),{signal}),b.create(ns(q),Uint8Array.of(8),{signal})]);check(result.filter(Boolean).length===1);const kept=await a.read(ns(q),{signal});check(kept[0]===(result[0]?7:8));check(await a.create(ns(q),Uint8Array.of(9),{signal})===false);let foreign=false,budget=false,abort=false;try{await a.read("foreign",{signal});}catch{foreign=true;}try{await a.create(ns(q),new Uint8Array(257),{signal});}catch{budget=true;}const c=new AbortController();c.abort();try{await a.read(ns(q),{signal:c.signal});}catch{abort=true;}check(foreign&&budget&&abort);check((await a.read(ns(q),{signal}))[0]===kept[0]);self.postMessage({id:data.id,atomicCreate:true,foreignBudgetAbortPreserved:true});}finally{a.close();b.close();}return;
diff --git a/runtime/packages/sdk/scripts/app-noise-idb-smoke.mjs b/runtime/packages/sdk/scripts/app-noise-idb-smoke.mjs
index fb308f263..917baad87 100644
--- a/runtime/packages/sdk/scripts/app-noise-idb-smoke.mjs
+++ b/runtime/packages/sdk/scripts/app-noise-idb-smoke.mjs
@@ -28,7 +28,7 @@ async function hostFixtureKey(mode){
}finally{db.close();}
}
self.onmessage=async e=>{let store,outcomeStore,rt;try{
- const signal=AbortSignal.timeout(15000),p=pin(),options={source:p,origin:location.origin,allowLoopbackHttp:true,mode:e.data.mode,signal};
+ const signal=AbortSignal.timeout(15000),p=pin(),options={source:p,origin:location.origin,mode:e.data.mode,signal};
if(e.data.case==="atomic"){
const q=pin("99999999-9999-9999-9999-999999999999"),opts={...options,source:q,mode:"fresh"};const a=await AppIndexedDbNoiseProtectedStore.open(opts),b=await AppIndexedDbNoiseProtectedStore.open(opts);
try{const x=Uint8Array.of(7),y=Uint8Array.of(8),results=await Promise.all([a.compareAndSet(ns(q),null,x,{signal}),b.compareAndSet(ns(q),null,y,{signal})]);check(results.filter(Boolean).length===1);check(x[0]===7&&y[0]===8);const kept=await a.read(ns(q),{signal});check(kept.length===1&&kept[0]===(results[0]?7:8));
@@ -58,7 +58,7 @@ let exported=false;try{await crypto.subtle.exportKey("raw",key);exported=true;}c
if(e.data.privateOutcome){
const session={...target,accountId:"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",cpOrigin:"https://cp.example",logOrigin:"https://log.example",rootPublicKey:new Uint8Array(32).fill(9),connectorBearer:async()=>{throw Error("known outcome must not request bearer");}};
const marker=rt.privateEnrolMarker(),receipt=rt.registeredDeviceReceipt();
- outcomeStore=await AppIndexedDbPrivateBootstrapStore.open({source:session,origin:location.origin,allowLoopbackHttp:true,mode:e.data.privateOutcome==="commit"?"fresh":"existing",signal});
+ outcomeStore=await AppIndexedDbPrivateBootstrapStore.open({source:session,origin:location.origin,mode:e.data.privateOutcome==="commit"?"fresh":"existing",signal});
let dropCompletion=false,dropAck=e.data.privateOutcome==="ack";
const outcomeIO={read:o=>outcomeStore.read(o),compareAndSet:async(expected,value,o)=>{const saved=await outcomeStore.compareAndSet(expected,value,o);if(dropCompletion)throw Error("committed completion response lost");return saved;}};
const noiseIO={privateEnrolPending:(m,o)=>vault.privateEnrolPending(m,o),privateEnrolAcknowledged:async(m,o)=>{await vault.privateEnrolAcknowledged(m,o);if(dropAck)throw Error("committed ACK response lost");}};
diff --git a/runtime/packages/sdk/scripts/app-private-persistence-smoke.mjs b/runtime/packages/sdk/scripts/app-private-persistence-smoke.mjs
index 873f22661..0f328f3da 100644
--- a/runtime/packages/sdk/scripts/app-private-persistence-smoke.mjs
+++ b/runtime/packages/sdk/scripts/app-private-persistence-smoke.mjs
@@ -24,7 +24,7 @@ async function fixtureKey(mode) {
}finally{db.close();}
}
self.onmessage=async e=>{let store;try{
- const p=source(),signal=AbortSignal.timeout(15000),options={source:p,origin:location.origin,allowLoopbackHttp:true,mode:e.data.mode,signal};
+ const p=source(),signal=AbortSignal.timeout(15000),options={source:p,origin:location.origin,mode:e.data.mode,signal};
const key=await fixtureKey(e.data.mode);let exported=false;try{await crypto.subtle.exportKey("raw",key);exported=true;}catch{}check(!exported);
const noise={privateEnrolPending:async()=>{throw Error("wrong fixture purpose");},privateEnrolAcknowledged:async()=>{throw Error("wrong fixture purpose");}};
if(e.data.phase==="cas"){
diff --git a/runtime/packages/sdk/scripts/app-sqlite-smoke.mjs b/runtime/packages/sdk/scripts/app-sqlite-smoke.mjs
index f543bbf91..4c5049cf0 100644
--- a/runtime/packages/sdk/scripts/app-sqlite-smoke.mjs
+++ b/runtime/packages/sdk/scripts/app-sqlite-smoke.mjs
@@ -63,10 +63,10 @@ onmessage=async ({data:m})=>{
let value;
if(m.op==='open') {
const start=performance.now();
- sqlite ??= await packagedApplicationAssets('lab',location.origin,m.platform).initializeSqlite({
+ sqlite ??= await packagedApplicationAssets('lab',location.origin).initializeSqlite({
wasmBinary:new Uint8Array(await (await fetch('/native/sqlite3.wasm')).arrayBuffer()),print:()=>{},printErr:()=>{}
});
- state=await openAppSahpoolIndex(sqlite,{...scope,collection:m.platform==='native'?'00000000-0000-4000-8000-000000000004':scope.collection});
+ state=await openAppSahpoolIndex(sqlite,scope);
host=new AppBinaryIndexHost(state.index);
value={opened:state.index.info.opened,durability:state.index.info.durability,openMs:performance.now()-start,synchronous:Number(query('PRAGMA synchronous')),journal:query('PRAGMA journal_mode'),locking:query('PRAGMA locking_mode')};
} else if(m.op==='fill') {
@@ -111,7 +111,6 @@ try {
if (!crossOriginIsolated) throw new Error("SQLite smoke requires cross-origin isolation");
let worker, id = 0;
const startWorker = () => { worker = new Worker("/worker.js", { type: "module" }); };
- let platform;
const call = (op, count) => new Promise((resolve, reject) => {
const requestId = ++id;
const timer = setTimeout(() => { worker.terminate(); reject(new Error("SQLite smoke timed out")); }, 15000);
@@ -121,11 +120,11 @@ try {
data.error ? reject(Object.assign(new Error("SQLite smoke operation failed"), { kind: data.kind })) : resolve(data.value);
};
worker.onerror = () => { clearTimeout(timer); reject(new Error("SQLite smoke Worker failed")); };
- worker.postMessage({ id: requestId, op, count, platform });
+ worker.postMessage({ id: requestId, op, count });
});
const results = [];
try {
- for (platform of [undefined, 'native']) for (const size of [1000, 5000, 20000]) {
+ for (const size of [1000, 5000, 20000]) {
startWorker();
const open = await call("open");
let secondOpener = null;
@@ -147,7 +146,7 @@ try {
const clean = await call("open");
if (clean.opened !== "Existing") throw new Error("clean reopen mismatch");
await call("close"); worker.terminate();
- results.push({ platform:platform ?? 'web', size, open, secondOpener, fill, recovered, readback, clean });
+ results.push({ size, open, secondOpener, fill, recovered, readback, clean });
}
return results;
} finally { worker?.terminate(); }
diff --git a/runtime/packages/sdk/src/app-host/app-environment.ts b/runtime/packages/sdk/src/app-host/app-environment.ts
index f61c410ca..5481592b2 100644
--- a/runtime/packages/sdk/src/app-host/app-environment.ts
+++ b/runtime/packages/sdk/src/app-host/app-environment.ts
@@ -3,34 +3,33 @@
* Release manifest/tool/asset authentication must precede this selection. */
import type { AppBundledReleaseTrust } from "./cloud-copy-bootstrap.js";
import { isAppOrigin } from "./app-origin.js";
-import { applicationEnvironmentAllowed, applicationLoopbackAllowed, labLoopbackHttpOrigin, localApplicationEnvironment, localApplicationOrigin } from "../application/local-build.js";
+import { applicationEnvironmentAllowed, serviceLoopbackAllowed, loopbackHttpOrigin, localApplicationEnvironment, localApplicationOrigin } from "../application/local-build.js";
export interface AppEnvironmentSelection {
readonly environment: "lab" | "staging" | "production" | "local";
readonly appOrigin: string;
readonly cpOrigin: string;
readonly logOrigin: string;
readonly assetSha256: string;
+ /** mdbase's own origins (CP, log) may be loopback HTTP: LAB and local builds only. */
readonly allowLoopbackHttp: boolean;
- /** Native app-origin storage only; never CP/log HTTP or test trust. */
- readonly allowNativeLoopbackHttp: boolean;
}
const selections = new WeakSet