From cca89c6a8e8c079869d228e0e2903e85384fde17 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 10 Nov 2026 18:21:25 +1100 Subject: [PATCH 1/2] feat(sdk): accept a localhost app origin in every environment A loopback HTTP origin (localhost, 127.0.0.1 or [::1], any port) is now an admissible application origin in LAB, staging and production. The SDK accepts it for the declaration, storage, assets and return URL; the server registers and pairs a manifest whose homepage is that origin; the approval screen describes it as an app running on this computer with an unverified identity. Connect's own origins stay HTTPS outside LAB and local builds. This replaces the native-platform 127.0.0.1 special case and removes MDBASE_CONNECT_ALLOW_INSECURE_MANIFESTS, allowNativeLoopbackHttp and the platform argument that carried it. Also let the npm-installed SDK start under a development server: a version query on the SDK's module URL no longer makes it refuse its packaged files. --- apps/portal/src/api.ts | 2 - apps/portal/src/authority-workflows.tsx | 2 +- apps/portal/src/authorization-view.tsx | 19 +++-- apps/portal/src/local-app-consent.test.tsx | 32 ++++++++ .../src/native-platform-consent.test.tsx | 23 ------ .../src/pairing-picker-consent.test.tsx | 8 +- .../localhost-app-development-removed.md | 4 + changelog.d/localhost-app-development.md | 3 + changelog.d/sdk-dev-server-module-url.md | 3 + deploy/self-host/compose.yml | 1 - deploy/tailnet/compose.yml | 1 - docker-compose.yml | 1 - docs/application-installation-pairing.md | 10 ++- docs/local-app-development.md | 74 +++++++++++++++++++ docs/mcp-gateway.md | 1 - docs/native-app-platform.md | 8 +- docs/testing-environment.md | 6 +- packages/protocol/src/manifest.ts | 12 +++ runtime/packages/sdk/README.md | 28 ++++++- runtime/packages/sdk/public-api.txt | 19 +---- .../scripts/app-cloud-copy-outcome-smoke.mjs | 4 +- .../scripts/app-device-key-custody-smoke.mjs | 2 +- .../sdk/scripts/app-noise-idb-smoke.mjs | 4 +- .../scripts/app-private-persistence-smoke.mjs | 2 +- .../packages/sdk/scripts/app-sqlite-smoke.mjs | 11 ++- .../sdk/src/app-host/app-environment.ts | 16 ++-- .../packages/sdk/src/app-host/app-origin.ts | 16 ++-- .../sdk/src/app-host/cloud-copy-bootstrap.ts | 4 +- .../sdk/src/app-host/cloud-copy-host.ts | 23 ++---- .../app-host/cloud-copy-persistence-idb.ts | 6 +- .../packages/sdk/src/app-host/cp-authority.ts | 4 +- .../packages/sdk/src/app-host/cp-people.ts | 4 +- .../sdk/src/app-host/device-key-idb.ts | 4 +- .../sdk/src/app-host/device-key-provider.ts | 5 +- .../sdk/src/app-host/device-registration.ts | 4 +- runtime/packages/sdk/src/app-host/http-log.ts | 4 +- .../sdk/src/app-host/installation-sign-in.ts | 6 +- .../sdk/src/app-host/installation-store.ts | 10 +-- runtime/packages/sdk/src/app-host/log-push.ts | 4 +- .../packages/sdk/src/app-host/noise-idb.ts | 5 +- .../sdk/src/app-host/private-bootstrap.ts | 4 +- .../src/app-host/private-persistence-idb.ts | 4 +- .../sdk/src/app-host/private-persistence.ts | 4 +- .../sdk/src/app-host/web-collection.ts | 3 +- .../sdk/src/app-host/web-sign-in-session.ts | 4 +- .../sdk/src/application/asset-loader.ts | 5 +- .../sdk/src/application/browser-selection.ts | 2 +- .../sdk/src/application/declaration.ts | 4 +- runtime/packages/sdk/src/application/entry.ts | 12 +-- .../sdk/src/application/local-build.ts | 6 +- .../packages/sdk/src/application/manifest.ts | 3 +- .../sdk/src/application/native-assets.ts | 7 +- .../sdk/src/application/packaged-assets.ts | 9 ++- .../sdk/src/application/portal-reservation.ts | 4 +- .../sdk/src/application/registration.ts | 6 +- .../sdk/src/application/return-url.ts | 13 ++-- .../packages/sdk/src/application/runtime.ts | 4 +- .../sdk/src/application/worker-session.ts | 2 +- .../packages/sdk/src/application/worker.ts | 2 +- runtime/packages/sdk/src/local-build.ts | 11 ++- .../sdk/test/app-device-key-provider.test.ts | 2 +- .../packages/sdk/test/app-environment.test.ts | 12 ++- .../packages/sdk/test/app-noise-idb.test.ts | 2 +- .../sdk/test/application-asset-loader.test.ts | 8 +- .../sdk/test/application-declaration.test.ts | 13 +++- .../test/application-native-platform.test.ts | 26 +++---- .../test/application-packaged-assets.test.ts | 7 ++ .../sdk/test/local-transport-origins.test.ts | 4 +- ...ts => loopback-app-origin-storage.test.ts} | 37 ++++------ scripts/dev-environment.mjs | 1 - scripts/e2e.mjs | 1 - scripts/hosted-provider-e2e.mjs | 3 +- scripts/lib/connect-environment.mjs | 3 - scripts/stress/systems.mjs | 1 - scripts/sync-e2e.mjs | 1 - services/server/src/app.test.ts | 12 +-- services/server/src/app.ts | 6 +- .../src/features/applications/routes.ts | 11 +-- .../sdk-registration.postgres.test.ts | 2 +- .../fresh-application-issuance.test.ts | 2 +- .../authorizations/live-authorization.test.ts | 3 +- .../connectors/installation-app.test.ts | 23 ++++++ .../installation-pairing.postgres.test.ts | 2 +- .../connectors/installation-pairing.ts | 8 +- services/server/src/index.ts | 1 - services/server/src/manifest.ts | 9 +-- test/upgrade/server-from-previous | 1 - 87 files changed, 405 insertions(+), 300 deletions(-) create mode 100644 apps/portal/src/local-app-consent.test.tsx delete mode 100644 apps/portal/src/native-platform-consent.test.tsx create mode 100644 changelog.d/localhost-app-development-removed.md create mode 100644 changelog.d/localhost-app-development.md create mode 100644 changelog.d/sdk-dev-server-module-url.md create mode 100644 docs/local-app-development.md rename runtime/packages/sdk/test/{native-loopback-storage.test.ts => loopback-app-origin-storage.test.ts} (73%) diff --git a/apps/portal/src/api.ts b/apps/portal/src/api.ts index 2e56364ce..672056ce9 100644 --- a/apps/portal/src/api.ts +++ b/apps/portal/src/api.ts @@ -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; @@ -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; diff --git a/apps/portal/src/authority-workflows.tsx b/apps/portal/src/authority-workflows.tsx index 132909660..682924282 100644 --- a/apps/portal/src/authority-workflows.tsx +++ b/apps/portal/src/authority-workflows.tsx @@ -367,7 +367,7 @@ export function Pairing({ pairingId }: { pairingId: string }) { ) : ( <> + homepage: pairing.app_origin ?? "", expires_at: pairing.expires_at }} showClientKey={false} /> {installationAccountLabel(pairing) ? (

Account: {literal(installationAccountLabel(pairing))}

) : ( 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 ? 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(); const invalid = () => new Error("app environment: explicit authenticated build context required"); function origin(value: unknown, loopbackAllowed: boolean): string { if (typeof value !== "string" || !value.length) throw invalid(); const u = new URL(value); - const loopback = loopbackAllowed && labLoopbackHttpOrigin(value); + const loopback = loopbackAllowed && loopbackHttpOrigin(value); if (u.origin !== value || u.username || u.password || (u.protocol !== "https:" && !loopback)) throw invalid(); return value; } /** No environment or app-origin default. CP/log origins come only from the * preauthenticated build release, whose environment must match exactly. - * LAB app loopback is explicit; production loopback requires a native manifest. + * A loopback app origin is an application under development (or a native one); + * it never relaxes HTTPS for the CP or log outside LAB. * Same-origin storage preservation is an app gate, not a runtime fallback. */ export function selectAppEnvironment(input: { environment: "lab" | "staging" | "production" | "local"; appOrigin: string; - platform?: "native"; release: AppBundledReleaseTrust; }): AppEnvironmentSelection { const {environment, release} = input; @@ -47,10 +46,9 @@ export function selectAppEnvironment(input: { const cpOrigin = origin(release.cpOrigin, local), logOrigin = origin(release.logOrigin, local); const appOrigin = input.appOrigin; if (local && ![cpOrigin, logOrigin, appOrigin].every(localApplicationOrigin)) throw invalid(); - if (!isAppOrigin(appOrigin, applicationLoopbackAllowed(environment), input.platform)) throw invalid(); + if (!isAppOrigin(appOrigin)) throw invalid(); const selected = Object.freeze({environment, appOrigin, cpOrigin, logOrigin, assetSha256: release.assetSha256, - allowLoopbackHttp: applicationLoopbackAllowed(environment) && new URL(appOrigin).protocol === "http:", - allowNativeLoopbackHttp: input.platform === "native" && isAppOrigin(appOrigin, false, "native") && new URL(appOrigin).protocol === "http:"}); + allowLoopbackHttp: serviceLoopbackAllowed(environment) && new URL(appOrigin).protocol === "http:"}); selections.add(selected); return selected; } /** Only a previously validated immutable selection, not a copied/redirect object. diff --git a/runtime/packages/sdk/src/app-host/app-origin.ts b/runtime/packages/sdk/src/app-host/app-origin.ts index cd77a133e..94f445327 100644 --- a/runtime/packages/sdk/src/app-host/app-origin.ts +++ b/runtime/packages/sdk/src/app-host/app-origin.ts @@ -1,9 +1,10 @@ import {isNativeLoopbackOrigin} from "@mdbase-dev/connect-protocol/manifest"; -/** App origins the SDK can run under: https (and LAB loopback http) web - * origins, declared native IPv4 loopback HTTP on any port, Capacitor's iOS `capacitor://` and browser-extension pages. +/** App origins the SDK can run under: https web origins, loopback http (an + * application under development, or a native one), Capacitor's iOS + * `capacitor://` and browser-extension pages. * Browsers may report custom app schemes as an opaque "null" origin, so the * canonical form is always `//`. Exact match, no wildcards. */ -import { labLoopbackHttpOrigin } from "../application/local-build.js"; +import { loopbackHttpOrigin } from "../application/local-build.js"; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; export type AppOriginPlatform = "web" | "mobile" | "extension" | "native"; @@ -12,20 +13,23 @@ export function urlOrigin(url: URL): string { return url.protocol === "https:" || url.protocol === "http:" ? url.origin : `${url.protocol}//${url.host}`; } -export function appOriginPlatform(value: unknown, loopbackHttp = false, platform?: "native"): AppOriginPlatform | null { +/** The platform an app origin runs on, or null when it is not an app origin. + * A manifest that declares `platform: "native"` is held to the canonical IPv4 + * loopback origin its manifest rule requires. */ +export function appOriginPlatform(value: unknown, declared?: "native"): AppOriginPlatform | null { if (typeof value !== "string" || value.length > 512) return null; let u: URL; try { u = new URL(value); } catch { return null; } if (u.username || u.password || u.search || u.hash || !["", "/"].includes(u.pathname) || urlOrigin(u) !== value) return null; switch (u.protocol) { case "https:": return "web"; - case "http:": return platform === "native" && isNativeLoopbackOrigin(value) ? "native" : loopbackHttp && labLoopbackHttpOrigin(value) ? "web" : null; + case "http:": return declared === "native" ? (isNativeLoopbackOrigin(value) ? "native" : null) : loopbackHttpOrigin(value) ? "web" : null; case "capacitor:": return !u.port && /^[a-z0-9.-]{1,253}$/.test(u.hostname) ? "mobile" : null; case "chrome-extension:": return /^[a-p]{32}$/.test(u.host) ? "extension" : null; case "moz-extension:": return UUID.test(u.host) ? "extension" : null; default: return null; } } -export const isAppOrigin = (value: unknown, loopbackHttp = false, platform?: "native"): boolean => appOriginPlatform(value, loopbackHttp, platform) !== null; +export const isAppOrigin = (value: unknown): boolean => appOriginPlatform(value) !== null; /** This document's or Worker's own app origin, or null outside a browser. */ export function currentAppOrigin(): string | null { diff --git a/runtime/packages/sdk/src/app-host/cloud-copy-bootstrap.ts b/runtime/packages/sdk/src/app-host/cloud-copy-bootstrap.ts index 3813db7b1..a499dd59c 100644 --- a/runtime/packages/sdk/src/app-host/cloud-copy-bootstrap.ts +++ b/runtime/packages/sdk/src/app-host/cloud-copy-bootstrap.ts @@ -1,7 +1,7 @@ /** First-party original-device CLOUD COPY HTTP bootstrap. No hosted-first, * seed loan, generic signer, runtime roots/keys discovery or readiness inference. */ import { uuidToBytes } from "../codec.js"; -import { LOCAL_APPLICATION_BUILD, labLoopbackHttpOrigin, localApplicationEnvironment, localApplicationOrigin } from "../application/local-build.js"; +import { LOCAL_APPLICATION_BUILD, loopbackHttpOrigin, localApplicationEnvironment, localApplicationOrigin } from "../application/local-build.js"; import { collectionDisplayName } from "./collection-display-name.js"; import type { AppDeviceKeyCustodyPin } from "./device-key-custody.js"; import type { @@ -282,7 +282,7 @@ export class AppCpCloudCopyBootstrap { u.username || u.password || (u.protocol !== "https:" && - !(this.loopback && labLoopbackHttpOrigin(raw))) + !(this.loopback && loopbackHttpOrigin(raw))) ) throw fail("binding"); return u.origin; diff --git a/runtime/packages/sdk/src/app-host/cloud-copy-host.ts b/runtime/packages/sdk/src/app-host/cloud-copy-host.ts index 5c43f6a3f..455f8282f 100644 --- a/runtime/packages/sdk/src/app-host/cloud-copy-host.ts +++ b/runtime/packages/sdk/src/app-host/cloud-copy-host.ts @@ -278,7 +278,6 @@ export class AppWebCloudCopyHost { loadRuntime(options: { signal: AbortSignal }): Promise; fetch?: typeof fetch; allowLoopbackHttp?: boolean; - allowNativeLoopbackHttp?: boolean; }): Promise { const { session, @@ -290,7 +289,6 @@ export class AppWebCloudCopyHost { loadRuntime, fetch: request, allowLoopbackHttp, - allowNativeLoopbackHttp, } = options; let host: AppWebCloudCopyHost | null = null; try { @@ -302,7 +300,7 @@ export class AppWebCloudCopyHost { origin, signal, ); - const {custody, restored} = await host.openOwnedDevice({mode, signal, loadRuntime, allowLoopbackHttp, allowNativeLoopbackHttp, requireReceipt: true}); + const {custody, restored} = await host.openOwnedDevice({mode, signal, loadRuntime, requireReceipt: true}); if (mode === "existing") { host.native!.acknowledgeDeviceRegistration(restored!.receipt!); host.check(signal); @@ -341,9 +339,8 @@ export class AppWebCloudCopyHost { signal: AbortSignal; loadRuntime(options: {signal: AbortSignal}): Promise; allowLoopbackHttp?: boolean; - allowNativeLoopbackHttp?: boolean; }): Promise { - const {signIn, installation, release, origin, signal, loadRuntime, allowLoopbackHttp, allowNativeLoopbackHttp} = options; + const {signIn, installation, release, origin, signal, loadRuntime, allowLoopbackHttp} = options; let host: AppWebCloudCopyHost | null = null; try { const session = signIn.confirmedHostSession(release.logOrigin, release.environment); @@ -351,7 +348,7 @@ export class AppWebCloudCopyHost { host.signIn = signIn; const prepared = await signIn.prepareOriginalDevice(installation); host.check(signal); - const {custody, restored} = await host.openOwnedDevice({mode: prepared.mode, signal, loadRuntime, allowLoopbackHttp, allowNativeLoopbackHttp, requireReceipt: false}); + const {custody, restored} = await host.openOwnedDevice({mode: prepared.mode, signal, loadRuntime, requireReceipt: false}); host.originalCustody = custody; if (restored?.receipt && signIn.view().state !== "paired") throw fail("recovery_required"); // Protect original Noise immediately, even before explicit attestation. @@ -390,19 +387,17 @@ export class AppWebCloudCopyHost { mode: "fresh" | "existing"; signal: AbortSignal; loadRuntime(options: {signal: AbortSignal}): Promise; - allowLoopbackHttp?: boolean; - allowNativeLoopbackHttp?: boolean; requireReceipt: boolean; }) { - const {mode, signal, loadRuntime, allowLoopbackHttp, allowNativeLoopbackHttp, requireReceipt} = options; + const {mode, signal, loadRuntime, requireReceipt} = options; this.check(signal); - this.provider = await AppIndexedDbDeviceKeyProvider.open({source: this.pin, origin: this.origin, mode, signal, allowLoopbackHttp, allowNativeLoopbackHttp}); + this.provider = await AppIndexedDbDeviceKeyProvider.open({source: this.pin, origin: this.origin, mode, signal}); this.check(signal); this.key = this.provider.deviceCustodyKek({signal}); this.check(signal); - this.keys = await AppIndexedDbDeviceKeyProtectedStore.open({source: this.pin, origin: this.origin, mode, signal, allowLoopbackHttp, allowNativeLoopbackHttp}); + this.keys = await AppIndexedDbDeviceKeyProtectedStore.open({source: this.pin, origin: this.origin, mode, signal}); this.check(signal); - this.noiseStore = await AppIndexedDbNoiseProtectedStore.open({source: this.pin, origin: this.origin, mode, signal, allowLoopbackHttp, allowNativeLoopbackHttp}); + this.noiseStore = await AppIndexedDbNoiseProtectedStore.open({source: this.pin, origin: this.origin, mode, signal}); this.check(signal); this.noise = new AppWebNoiseCustody(this.pin, this.key, this.noiseStore); const restored = await this.noise.restore({signal}); @@ -435,7 +430,6 @@ export class AppWebCloudCopyHost { displayName?: string; fetch?: typeof fetch; allowLoopbackHttp?: boolean; - allowNativeLoopbackHttp?: boolean; }): Promise { const { scope, @@ -446,7 +440,6 @@ export class AppWebCloudCopyHost { reconcile, fetch: request, allowLoopbackHttp, - allowNativeLoopbackHttp, } = options; const signal = this.operationSignal(callerSignal); const requestedName = options.displayName; @@ -508,8 +501,6 @@ export class AppWebCloudCopyHost { origin: this.origin, mode: outcomeMode, signal, - allowLoopbackHttp, - allowNativeLoopbackHttp, }); this.checkCollection(signal); this.outcome = new AppWebCloudCopyBootstrapPersistence( diff --git a/runtime/packages/sdk/src/app-host/cloud-copy-persistence-idb.ts b/runtime/packages/sdk/src/app-host/cloud-copy-persistence-idb.ts index cfe3efbe5..41b55bab7 100644 --- a/runtime/packages/sdk/src/app-host/cloud-copy-persistence-idb.ts +++ b/runtime/packages/sdk/src/app-host/cloud-copy-persistence-idb.ts @@ -31,8 +31,6 @@ export interface AppIndexedDbCloudCopyOutcomeOptions { readonly origin: string; readonly mode: "fresh" | "existing"; readonly signal: AbortSignal; - readonly allowLoopbackHttp?: boolean; - readonly allowNativeLoopbackHttp?: boolean; } export class AppIndexedDbCloudCopyOutcomeStore implements AppCloudCopyOutcomeStore { private db: IDBDatabase | null = null; @@ -97,7 +95,7 @@ export class AppIndexedDbCloudCopyOutcomeStore implements AppCloudCopyOutcomeSto static async open( options: AppIndexedDbCloudCopyOutcomeOptions, ): Promise { - const { source, origin, mode, signal, allowLoopbackHttp } = options; + const { source, origin, mode, signal } = options; let out: AppIndexedDbCloudCopyOutcomeStore | null = null; try { out = new AppIndexedDbCloudCopyOutcomeStore(source, origin, signal); @@ -112,7 +110,7 @@ export class AppIndexedDbCloudCopyOutcomeStore implements AppCloudCopyOutcomeSto return id.toLowerCase(); }); if ( - !isAppOrigin(origin, allowLoopbackHttp === true, options.allowNativeLoopbackHttp === true ? "native" : undefined) || + !isAppOrigin(origin) || !["fresh", "existing"].includes(mode) || !["create", "join"].includes(out.fields.purpose) || !/^[0-9a-f]{64}$/.test(out.fields.assetSha256) || diff --git a/runtime/packages/sdk/src/app-host/cp-authority.ts b/runtime/packages/sdk/src/app-host/cp-authority.ts index 7dc55120b..7b81a42bf 100644 --- a/runtime/packages/sdk/src/app-host/cp-authority.ts +++ b/runtime/packages/sdk/src/app-host/cp-authority.ts @@ -1,6 +1,6 @@ /** Enrolled-device CP log-token refresh + protected WASM proof authority. * Not registration, account login, key custody storage or readiness admission. */ -import { labLoopbackHttpOrigin } from "../application/local-build.js"; +import { loopbackHttpOrigin } from "../application/local-build.js"; import type { AppLogTokenCache } from "./log-token-cache.js"; import { uuidToBytes } from "../codec.js"; import { AppHttpLogTransport, type AppLogHttpAuthority, type AppLogHttpOptions, type AppLogHttpProof } from "./http-log.js"; @@ -22,7 +22,7 @@ const fail = (r: AppCpAuthorityError["reason"]) => new AppCpAuthorityError(r); const refused = (server: AppServerProblem) => new AppCpAuthorityError(appServerOutcome(server) ?? "unavailable", server); function origin(input: string, loopback: boolean): string { let u: URL; try { u = new URL(input); } catch { throw fail("binding"); } - if (u.username || u.password || u.pathname !== "/" || u.search || u.hash || (u.protocol !== "https:" && !(loopback && labLoopbackHttpOrigin(input)))) throw fail("binding"); + if (u.username || u.password || u.pathname !== "/" || u.search || u.hash || (u.protocol !== "https:" && !(loopback && loopbackHttpOrigin(input)))) throw fail("binding"); return u.origin; } function uint(v: number | bigint): bigint { if (typeof v === "number" && !Number.isSafeInteger(v)) throw fail("binding"); const n = BigInt(v); if (n < 0n || n > (1n << 64n) - 1n) throw fail("binding"); return n; } diff --git a/runtime/packages/sdk/src/app-host/cp-people.ts b/runtime/packages/sdk/src/app-host/cp-people.ts index e6dcaaf97..2e816edef 100644 --- a/runtime/packages/sdk/src/app-host/cp-people.ts +++ b/runtime/packages/sdk/src/app-host/cp-people.ts @@ -1,5 +1,5 @@ /** Fixed, pinned CP people GETs. No record payload, path, key, or issuer inference. */ -import { labLoopbackHttpOrigin } from "../application/local-build.js"; +import { loopbackHttpOrigin } from "../application/local-build.js"; import { uuidToBytes } from "../codec.js"; import { mdbaseError, MdbaseError } from "../errors.js"; import type { PeopleAuthority, PeopleRequestOptions } from "../people.js"; @@ -31,7 +31,7 @@ export class AppCpPeopleAuthority implements PeopleAuthority { this.rawOrigin = session.cpOrigin; const url = new URL(this.rawOrigin); if (url.username || url.password || url.pathname !== "/" || url.search || url.hash || - (url.protocol !== "https:" && !(options.allowLoopbackHttp === true && labLoopbackHttpOrigin(session.cpOrigin)))) + (url.protocol !== "https:" && !(options.allowLoopbackHttp === true && loopbackHttpOrigin(session.cpOrigin)))) throw mdbaseError("invalid_request", "invalid people CP origin", "people_binding"); this.cpOrigin = url.origin; this.collection = session.collection; this.connector = session.connectorId; this.device = session.deviceId; this.ep = endpoint(session.endpoint); diff --git a/runtime/packages/sdk/src/app-host/device-key-idb.ts b/runtime/packages/sdk/src/app-host/device-key-idb.ts index 04905076b..6b4f692c3 100644 --- a/runtime/packages/sdk/src/app-host/device-key-idb.ts +++ b/runtime/packages/sdk/src/app-host/device-key-idb.ts @@ -14,8 +14,6 @@ export interface AppIndexedDbDeviceKeyOptions { readonly mode: "fresh" | "existing"; readonly source: AppDeviceKeyCustodyPin; readonly signal: AbortSignal; - readonly allowLoopbackHttp?: boolean; - readonly allowNativeLoopbackHttp?: boolean; } export class AppIndexedDbDeviceKeyProtectedStore implements AppDeviceKeyProtectedStore { private closed=false; @@ -37,7 +35,7 @@ export class AppIndexedDbDeviceKeyProtectedStore implements AppDeviceKeyProtecte const current=source.isCurrent,owned=source.installationOwned; const isCurrent=()=>{try{return !signal.aborted && source.isCurrent===current && source.installationOwned===owned && current.call(source)===true && owned.call(source)===true && (Object.keys(scope) as (keyof typeof scope)[]).every(k=>source[k]===scope[k]);}catch{return false;}}; const ids=[scope.accountId,scope.connectorId,scope.deviceId,scope.installationId].map(v=>{const b=uuidToBytes(v);if(b.every(v=>v===0))throw fail();return Array.from(b,v=>v.toString(16).padStart(2,"0")).join("");}); - if (!isAppOrigin(origin, options.allowLoopbackHttp===true, options.allowNativeLoopbackHttp === true ? "native" : undefined) || currentAppOrigin()!==origin || !["fresh","existing"].includes(mode) || !isCurrent() || !globalThis.indexedDB) throw fail(); + if (!isAppOrigin(origin) || currentAppOrigin()!==origin || !["fresh","existing"].includes(mode) || !isCurrent() || !globalThis.indexedDB) throw fail(); const namespace=`mdbase.app-device-keys.v1:${ids.join(":")}`,name=`mdbase.app.device-keys.v1.${ids.join(".")}`; const db=await new Promise((resolve,reject)=>{ const request=indexedDB.open(name,1);let abandoned=false; diff --git a/runtime/packages/sdk/src/app-host/device-key-provider.ts b/runtime/packages/sdk/src/app-host/device-key-provider.ts index 6feee4e63..8f7a1a756 100644 --- a/runtime/packages/sdk/src/app-host/device-key-provider.ts +++ b/runtime/packages/sdk/src/app-host/device-key-provider.ts @@ -12,9 +12,6 @@ export interface AppIndexedDbDeviceKeyProviderOptions { readonly origin: string; readonly mode: "fresh" | "existing"; readonly signal: AbortSignal; - /** Owned isolated loopback fixtures only, never arbitrary HTTP. */ - readonly allowLoopbackHttp?: boolean; - readonly allowNativeLoopbackHttp?: boolean; } function key(value: unknown): CryptoKey { // Genuine platform key handle, not metadata alone; IDB clones into this realm. @@ -44,7 +41,7 @@ export class AppIndexedDbDeviceKeyProvider { const {source,origin,mode,signal}=options; out=new AppIndexedDbDeviceKeyProvider(source,origin,signal); const ids=Object.values(out.scope).map(v=>{const bytes=uuidToBytes(v);if(bytes.every(b=>b===0))throw fail();return Array.from(bytes,b=>b.toString(16).padStart(2,"0")).join("");}); - if(!isAppOrigin(origin,options.allowLoopbackHttp===true, options.allowNativeLoopbackHttp === true ? "native" : undefined)||!["fresh","existing"].includes(mode)||!globalThis.indexedDB||!globalThis.crypto?.subtle||typeof globalThis.CryptoKey!=="function")throw fail(); + if(!isAppOrigin(origin)||!["fresh","existing"].includes(mode)||!globalThis.indexedDB||!globalThis.crypto?.subtle||typeof globalThis.CryptoKey!=="function")throw fail(); out.check(signal); const generate=crypto.subtle.generateKey.bind(crypto.subtle); const name=`mdbase.app.device-key-kek.v1.${ids.join(".")}`; diff --git a/runtime/packages/sdk/src/app-host/device-registration.ts b/runtime/packages/sdk/src/app-host/device-registration.ts index d14d61bc0..f27c3d807 100644 --- a/runtime/packages/sdk/src/app-host/device-registration.ts +++ b/runtime/packages/sdk/src/app-host/device-registration.ts @@ -1,6 +1,6 @@ /** First-party protected device registration ONLY. No collection bootstrap, * private-enrol/create/SAS/Noise handshake, retry or provider activation. */ -import { labLoopbackHttpOrigin } from "../application/local-build.js"; +import { loopbackHttpOrigin } from "../application/local-build.js"; import { uuidToBytes } from "../codec.js"; import type { AppDeviceIdentityPin, AppDeviceRegistrationReceipt, AppNoiseCustodyResult, AppWasmRuntime } from "./wasm-runtime.js"; @@ -39,7 +39,7 @@ export class AppCpDeviceRegistration { for (const id of [this.scope.connectorId,this.scope.deviceId,this.scope.installationId]) if (uuidToBytes(id).every(b=>b===0)) throw fail("binding"); if (session.kind!=="mobile" && session.kind!=="app-runtime") throw fail("binding"); this.rawOrigin=session.cpOrigin;const u=new URL(this.rawOrigin); - if (u.username || u.password || u.pathname!=="/" || u.search || u.hash || (u.protocol!=="https:" && !(options.allowLoopbackHttp===true && labLoopbackHttpOrigin(this.rawOrigin)))) throw fail("binding"); + if (u.username || u.password || u.pathname!=="/" || u.search || u.hash || (u.protocol!=="https:" && !(options.allowLoopbackHttp===true && loopbackHttpOrigin(this.rawOrigin)))) throw fail("binding"); this.origin=u.origin;this.now=options.now??Date.now;this.fetchImpl=options.fetch??globalThis.fetch; if (!(custody.envelope instanceof Uint8Array) || !custody.envelope.length || custody.envelope.length>1024) throw fail("binding"); if (!runtime.deviceCustodyCurrent(session,custody)) throw fail("binding"); diff --git a/runtime/packages/sdk/src/app-host/http-log.ts b/runtime/packages/sdk/src/app-host/http-log.ts index e53a6157c..3e02d0ea9 100644 --- a/runtime/packages/sdk/src/app-host/http-log.ts +++ b/runtime/packages/sdk/src/app-host/http-log.ts @@ -1,5 +1,5 @@ /** First-party unary LS HTTP + staged object transfers. No account/key custody defaults. */ -import { labLoopbackHttpOrigin } from "../application/local-build.js"; +import { loopbackHttpOrigin } from "../application/local-build.js"; import { sha256 } from "@noble/hashes/sha2.js"; import { decode, encode, type CborValue } from "../cbor.js"; import { uuidToBytes } from "../codec.js"; @@ -67,7 +67,7 @@ function hex(b: Uint8Array): string { return [...b].map(v => v.toString(16).padS function base64(b: Uint8Array): string { return btoa(String.fromCharCode(...b)); } function origin(input: string, loopback: boolean): string { let u: URL; try { u = new URL(input); } catch { throw error("binding"); } - if (u.username || u.password || u.pathname !== "/" || u.search || u.hash || (u.protocol !== "https:" && !(loopback && labLoopbackHttpOrigin(input)))) throw error("binding"); + if (u.username || u.password || u.pathname !== "/" || u.search || u.hash || (u.protocol !== "https:" && !(loopback && loopbackHttpOrigin(input)))) throw error("binding"); return u.origin; } /** Fetch exposes decoded bytes; CORS can hide Content-Encoding. Wire length diff --git a/runtime/packages/sdk/src/app-host/installation-sign-in.ts b/runtime/packages/sdk/src/app-host/installation-sign-in.ts index ec6be59d4..19b12d2f9 100644 --- a/runtime/packages/sdk/src/app-host/installation-sign-in.ts +++ b/runtime/packages/sdk/src/app-host/installation-sign-in.ts @@ -287,13 +287,11 @@ export class AppProtectedInstallationSignIn { if (selection !== undefined) { if (!isAppEnvironmentSelection(selection) || selection.environment !== p.environment || selection.appOrigin !== p.origin || selection.cpOrigin !== p.cpOrigin || - (p.allowLoopbackHttp === true && !selection.allowLoopbackHttp) || - (p.allowNativeLoopbackHttp === true && !selection.allowNativeLoopbackHttp)) throw fail("binding"); + (p.allowLoopbackHttp === true && !selection.allowLoopbackHttp)) throw fail("binding"); } // The application supplies its exact origin; the origin-bound store and // CP registry authorize it. No brand-specific client allowlist. - opened = await AppInstallationStore.open({...p, allowLoopbackHttp: selection?.allowLoopbackHttp ?? p.allowLoopbackHttp, - allowNativeLoopbackHttp: selection?.allowNativeLoopbackHttp ?? p.allowNativeLoopbackHttp}, () => { + opened = await AppInstallationStore.open({...p, allowLoopbackHttp: selection?.allowLoopbackHttp ?? p.allowLoopbackHttp}, () => { const bytes = crypto.getRandomValues(new Uint8Array(32)); try { const secret = `pair_${btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`; diff --git a/runtime/packages/sdk/src/app-host/installation-store.ts b/runtime/packages/sdk/src/app-host/installation-store.ts index 4bf21c802..7a5e24106 100644 --- a/runtime/packages/sdk/src/app-host/installation-store.ts +++ b/runtime/packages/sdk/src/app-host/installation-store.ts @@ -1,7 +1,7 @@ /** Internal pre-account sign-in ledger. Nonextractable origin AES handle + bounded * encrypted operation in ONE strict IDB commit. Not an account KEK, generic KV, * OS keychain, XSS boundary or physical durability/collection Saved guarantee. */ -import { labLoopbackHttpOrigin } from "../application/local-build.js"; +import { loopbackHttpOrigin } from "../application/local-build.js"; import { currentAppOrigin, isAppOrigin } from "./app-origin.js"; import { acquireAppSignInLease, isAppInstallationId, isInstallationOwner, AppReplicaOwnerError, type AppLockPort, type AppReplicaLease } from "./owner.js"; const STORE = "installation-sign-in", KEY = "original", MAX = 128 * 1024; // bounded original + additive consent receipts (<=1000 IDs each) @@ -41,8 +41,6 @@ export interface AppInstallationStoreOptions { readonly signal: AbortSignal; readonly locks: AppLockPort | undefined; readonly allowLoopbackHttp?: boolean; - /** Native IPv4 app origin only; does not relax the CP origin. */ - readonly allowNativeLoopbackHttp?: boolean; } /** Deliberately NOT exported from the package entrypoint. Only the fixed sign-in * lifecycle uses its bounded ciphertext CAS; no secrets/handle loan API. */ @@ -59,12 +57,12 @@ export class AppInstallationStore { } static async open(options: AppInstallationStoreOptions, initial: () => Uint8Array): Promise<{store: AppInstallationStore; plaintext: Uint8Array}> { // Snapshot before any ownership/platform wait. No account ID is invented. - const p = Object.freeze({ origin: options.origin, cpOrigin: options.cpOrigin, appId: options.appId, ...(options.owner === undefined ? {} : {owner: options.owner}), ...(options.storageNamespace === undefined ? {} : {storageNamespace: options.storageNamespace}), mode: options.mode, signal: options.signal, locks: options.locks, allowLoopbackHttp: options.allowLoopbackHttp === true, allowNativeLoopbackHttp: options.allowNativeLoopbackHttp === true }); + const p = Object.freeze({ origin: options.origin, cpOrigin: options.cpOrigin, appId: options.appId, ...(options.owner === undefined ? {} : {owner: options.owner}), ...(options.storageNamespace === undefined ? {} : {storageNamespace: options.storageNamespace}), mode: options.mode, signal: options.signal, locks: options.locks, allowLoopbackHttp: options.allowLoopbackHttp === true }); let out: AppInstallationStore | null = null, plain: Uint8Array | null = null, fresh: Stored | null = null; try { - if (!isAppOrigin(p.origin, p.allowLoopbackHttp === true, p.allowNativeLoopbackHttp ? "native" : undefined)) throw fail(); + if (!isAppOrigin(p.origin)) throw fail(); { - const origin = p.cpOrigin, u = new URL(origin), loopback = p.allowLoopbackHttp && labLoopbackHttpOrigin(origin); + const origin = p.cpOrigin, u = new URL(origin), loopback = p.allowLoopbackHttp && loopbackHttpOrigin(origin); if (u.origin !== origin || u.username || u.password || (u.protocol !== "https:" && !loopback)) throw fail(); } if (p.owner !== undefined && !isInstallationOwner(p.owner)) throw fail(); diff --git a/runtime/packages/sdk/src/app-host/log-push.ts b/runtime/packages/sdk/src/app-host/log-push.ts index 15e2c1ef2..4057c2871 100644 --- a/runtime/packages/sdk/src/app-host/log-push.ts +++ b/runtime/packages/sdk/src/app-host/log-push.ts @@ -13,7 +13,7 @@ * frame carries its type at key 0 (1 response, 2 push) and every push a string * `kind` at key 1. Unknown frame types, push kinds and text messages are ignored, * never an error, so later message kinds need no protocol rework. */ -import { labLoopbackHttpOrigin } from "../application/local-build.js"; +import { loopbackHttpOrigin } from "../application/local-build.js"; import { decode, encode, type CborValue } from "../cbor.js"; import { uuidToBytes } from "../codec.js"; @@ -63,7 +63,7 @@ export interface AppLogPushOptions { function wsOrigin(input: string, loopback: boolean): string { const u = new URL(input); if (u.protocol === "https:") return `wss://${u.host}`; - if (loopback && labLoopbackHttpOrigin(input)) return `ws://${u.host}`; + if (loopback && loopbackHttpOrigin(input)) return `ws://${u.host}`; throw new Error("app log push: origin"); } const hex = (b: Uint8Array) => [...b].map(v => v.toString(16).padStart(2, "0")).join(""); diff --git a/runtime/packages/sdk/src/app-host/noise-idb.ts b/runtime/packages/sdk/src/app-host/noise-idb.ts index 48d3f70ef..fe58ce80c 100644 --- a/runtime/packages/sdk/src/app-host/noise-idb.ts +++ b/runtime/packages/sdk/src/app-host/noise-idb.ts @@ -33,9 +33,6 @@ export interface AppIndexedDbNoiseOptions { mode: "fresh" | "existing"; source: AppDeviceIdentityPin; signal: AbortSignal; - /** Isolated loopback fixtures only; never enables an arbitrary HTTP origin. */ - allowLoopbackHttp?: boolean; - readonly allowNativeLoopbackHttp?: boolean; } export class AppIndexedDbNoiseProtectedStore implements AppNoiseProtectedStore { @@ -53,7 +50,7 @@ export class AppIndexedDbNoiseProtectedStore implements AppNoiseProtectedStore { private static async openChecked(options: AppIndexedDbNoiseOptions): Promise { const { source, signal } = options; const pins = [source.connectorId, source.deviceId, source.installationId] as const; - if (!isAppOrigin(options.origin, options.allowLoopbackHttp === true, options.allowNativeLoopbackHttp === true ? "native" : undefined) || currentAppOrigin() !== options.origin || + if (!isAppOrigin(options.origin) || currentAppOrigin() !== options.origin || !["fresh", "existing"].includes(options.mode) || signal.aborted || !current(source, pins) || typeof globalThis.indexedDB === "undefined") throw fail(); const name = `mdbase.app.noise.v1.${id(pins[2])}.${id(pins[0])}.${id(pins[1])}`; diff --git a/runtime/packages/sdk/src/app-host/private-bootstrap.ts b/runtime/packages/sdk/src/app-host/private-bootstrap.ts index 1306dc104..279b39fa8 100644 --- a/runtime/packages/sdk/src/app-host/private-bootstrap.ts +++ b/runtime/packages/sdk/src/app-host/private-bootstrap.ts @@ -1,6 +1,6 @@ /** First-party DEVICE-phase private bootstrap. Fixed domains/routes, one explicit * attempt. Authenticated response metadata is NOT native policy/keyed/readiness. */ -import { labLoopbackHttpOrigin } from "../application/local-build.js"; +import { loopbackHttpOrigin } from "../application/local-build.js"; import { uuidToBytes } from "../codec.js"; import { collectionDisplayName } from "./collection-display-name.js"; import type { AppWasmRuntime, AppPrivateCollectionPin, AppPrivateEnrolOperationMarker, AppDeviceRegistrationReceipt } from "./wasm-runtime.js"; @@ -60,7 +60,7 @@ export class AppCpPrivateBootstrap { if(!this.current())throw fail("binding"); }catch{runtime.retireLog();throw fail("binding");} } - private origin(raw:string):string {const u=new URL(raw);if(u.username||u.password||u.pathname!=="/"||u.search||u.hash||(u.protocol!=="https:"&&!(this.options.allowLoopbackHttp===true&&labLoopbackHttpOrigin(raw))))throw fail("binding");return u.origin;} + private origin(raw:string):string {const u=new URL(raw);if(u.username||u.password||u.pathname!=="/"||u.search||u.hash||(u.protocol!=="https:"&&!(this.options.allowLoopbackHttp===true&&loopbackHttpOrigin(raw))))throw fail("binding");return u.origin;} private current():boolean {try{return !this.lifetime.signal.aborted&&this.runtime.privateCollectionCurrent(this.session)&&this.session.isCurrent()===true&&this.session.approvalMode==="password-ak1"&&equal(this.session.rootPublicKey,this.root)&&Object.entries(this.scope).every(([k,v])=>this.session[k as keyof AppCpPrivateSession]===v);}catch{return false;}} private markerCopy():AppPrivateEnrolOperationMarker {const m=this.marker;if(!m)throw fail("binding");return Object.freeze({...m,sasCommitment:new Uint8Array(m.sasCommitment),signPublicKey:new Uint8Array(m.signPublicKey),kemPublicKey:new Uint8Array(m.kemPublicKey),noisePublicKey:new Uint8Array(m.noisePublicKey)});} private check(signal:AbortSignal):void {if(!this.current()||signal.aborted){this.close();throw fail("fenced");}} diff --git a/runtime/packages/sdk/src/app-host/private-persistence-idb.ts b/runtime/packages/sdk/src/app-host/private-persistence-idb.ts index bdedcd90e..a41d5b159 100644 --- a/runtime/packages/sdk/src/app-host/private-persistence-idb.ts +++ b/runtime/packages/sdk/src/app-host/private-persistence-idb.ts @@ -18,8 +18,6 @@ export interface AppIndexedDbPrivateBootstrapOptions { /** Explicit operation-record decision, not inferred from missing custody. */ readonly mode: "fresh" | "existing"; readonly signal: AbortSignal; - readonly allowLoopbackHttp?: boolean; - readonly allowNativeLoopbackHttp?: boolean; } export class AppIndexedDbPrivateBootstrapStore implements AppPrivateBootstrapProtectedStore { private closed = false; @@ -37,7 +35,7 @@ export class AppIndexedDbPrivateBootstrapStore implements AppPrivateBootstrapPro for (const id of ids) if (uuidToBytes(id).every(v => v === 0)) throw bad(); if (!(source.rootPublicKey instanceof Uint8Array) || source.rootPublicKey.length !== 32 || source.rootPublicKey.every(v => v === 0)) throw bad(); const root = new Uint8Array(source.rootPublicKey); - if (!isAppOrigin(options.origin, options.allowLoopbackHttp === true, options.allowNativeLoopbackHttp === true ? "native" : undefined) || + if (!isAppOrigin(options.origin) || currentAppOrigin() !== options.origin || !["fresh", "existing"].includes(options.mode) || !["create", "enrol"].includes(pins.purpose) || !globalThis.indexedDB) throw bad(); const current = () => { try { return !signal.aborted && source.isCurrent() === true && source.approvalMode === "password-ak1" && source.rootPublicKey instanceof Uint8Array && same(source.rootPublicKey, root) && Object.entries(pins).every(([k, v]) => source[k as keyof AppCpPrivateSession] === v); } catch { return false; } }; diff --git a/runtime/packages/sdk/src/app-host/private-persistence.ts b/runtime/packages/sdk/src/app-host/private-persistence.ts index 704d3471b..3c78f4106 100644 --- a/runtime/packages/sdk/src/app-host/private-persistence.ts +++ b/runtime/packages/sdk/src/app-host/private-persistence.ts @@ -1,6 +1,6 @@ /** Fixed-purpose PUBLIC bootstrap outcome custody, not account/password/secret * custody. Unexported until persistent-platform/restart qualification completes. */ -import { labLoopbackHttpOrigin } from "../application/local-build.js"; +import { loopbackHttpOrigin } from "../application/local-build.js"; import { decode, encode, type CborValue } from "../cbor.js"; import { uuidToBytes } from "../codec.js"; import { collectionDisplayName } from "./collection-display-name.js"; @@ -40,7 +40,7 @@ export class AppWebPrivateBootstrapPersistence implements AppPrivateBootstrapPer const ids = [this.pins.accountId, this.pins.connectorId, this.pins.deviceId, this.pins.installationId, this.pins.collection].map(uuidToBytes); if (ids.some(b => b.every(v => v === 0)) || !["create", "enrol"].includes(this.pins.purpose)) throw bad(); for (const raw of [source.cpOrigin, source.logOrigin]) { - const u = new URL(raw), loopback = options.allowLoopbackHttp === true && labLoopbackHttpOrigin(raw); + const u = new URL(raw), loopback = options.allowLoopbackHttp === true && loopbackHttpOrigin(raw); if ((!loopback && u.protocol !== "https:") || u.origin !== raw || u.username || u.password) throw bad(); } if (!(source.rootPublicKey instanceof Uint8Array) || source.rootPublicKey.length !== 32 || source.rootPublicKey.every(v => v === 0)) throw bad(); diff --git a/runtime/packages/sdk/src/app-host/web-collection.ts b/runtime/packages/sdk/src/app-host/web-collection.ts index a8d1cef15..3224448f3 100644 --- a/runtime/packages/sdk/src/app-host/web-collection.ts +++ b/runtime/packages/sdk/src/app-host/web-collection.ts @@ -70,7 +70,6 @@ export interface AppWebCollectionAdapterOptions { handoff(value: AppWebCollectionHandoff): void; readonly fetch?: typeof fetch; readonly allowLoopbackHttp?: boolean; - readonly allowNativeLoopbackHttp?: boolean; } export class AppWebCollectionError extends Error { constructor(readonly reason: "binding" | "fenced" | "unavailable") { @@ -285,7 +284,7 @@ export function createAppWebCollectionAdapter(source: AppWebCollectionAdapterOpt report("joining"); await host.bootstrapCloudCopy({scope, collectionCurrent: current, purpose: "join", outcomeMode: attempt.outcomeMode, signal: life.signal, ...(attempt.outcomeMode === "existing" ? {reconcile: "explicit-unknown-outcome" as const} : {}), - fetch: options.fetch, allowLoopbackHttp: options.allowLoopbackHttp, allowNativeLoopbackHttp: options.allowNativeLoopbackHttp}); check(); + fetch: options.fetch, allowLoopbackHttp: options.allowLoopbackHttp}); check(); await ledger.complete({signal: life.signal}); check(); const receipt = host.registeredReceipt(); if (receipt.installationId !== scope.installation) throw fail("binding"); diff --git a/runtime/packages/sdk/src/app-host/web-sign-in-session.ts b/runtime/packages/sdk/src/app-host/web-sign-in-session.ts index a9cc334c6..f713b874c 100644 --- a/runtime/packages/sdk/src/app-host/web-sign-in-session.ts +++ b/runtime/packages/sdk/src/app-host/web-sign-in-session.ts @@ -232,7 +232,7 @@ export class AppWebSignInSession { constructor(options: AppWebSignInSessionOptions) { this.options = Object.freeze({...options}); // Validate the authenticated environment before touching storage or HTTP. - this.environment = selectAppEnvironment({environment: options.environment, appOrigin: options.origin, platform: options.allowNativeLoopbackHttp ? "native" : undefined, release: options.release}); + this.environment = selectAppEnvironment({environment: options.environment, appOrigin: options.origin, release: options.release}); options.signal.addEventListener("abort", this.onAbort, {once: true}); this.lifetime.signal.addEventListener("abort", this.abortCollections, {once: true}); } @@ -623,7 +623,7 @@ export class AppWebSignInSession { this.host = await AppWebCloudCopyHost.openInstallationDevice({signIn: this.flow!, installation: this.installation, release: this.options.release, origin: this.options.origin, signal: this.lifetime.signal, loadRuntime: this.options.loadRuntime, - allowLoopbackHttp: this.environment.allowLoopbackHttp, allowNativeLoopbackHttp: this.environment.allowNativeLoopbackHttp}); this.check(); + allowLoopbackHttp: this.environment.allowLoopbackHttp}); this.check(); this.acknowledged = this.flow!.view().state === "paired"; } else if (!this.acknowledged && this.flow!.view().state === "paired") { await this.host.completeInstallationSignIn(); this.check(); this.acknowledged = true; diff --git a/runtime/packages/sdk/src/application/asset-loader.ts b/runtime/packages/sdk/src/application/asset-loader.ts index b8c1637ae..e18d4258b 100644 --- a/runtime/packages/sdk/src/application/asset-loader.ts +++ b/runtime/packages/sdk/src/application/asset-loader.ts @@ -1,6 +1,6 @@ import {isAppOrigin,urlOrigin} from "../app-host/app-origin.js"; import {isMdbaseError,mdbaseError} from "../errors.js"; -import {applicationEnvironmentAllowed,applicationLoopbackAllowed,localApplicationEnvironment,localApplicationOrigin} from "./local-build.js"; +import {applicationEnvironmentAllowed,localApplicationEnvironment,localApplicationOrigin} from "./local-build.js"; /** SDK build/packaging input only, not an application declaration option. * Hash integrity does NOT authenticate this descriptor or the release context. */ @@ -12,7 +12,6 @@ export interface ApplicationAsset { } export interface ApplicationAssetHost { readonly origin:string; - readonly platform?:"native"; readonly environment:"lab"|"staging"|"production"|"local"; readonly fetch?:typeof fetch; } @@ -30,7 +29,7 @@ export function applicationAssetLoader(input:ApplicationAsset,source:Application const asset=Object.freeze({...input}),host=Object.freeze({...source}); let url:URL,origin:URL; try{url=new URL(asset.url);origin=new URL(host.origin);}catch{throw error("application_asset_binding");} - if(!isAppOrigin(host.origin,applicationLoopbackAllowed(host.environment),host.platform)||origin.protocol!==url.protocol + if(!isAppOrigin(host.origin)||origin.protocol!==url.protocol ||!applicationEnvironmentAllowed(host.environment)||(localApplicationEnvironment(host.environment)&&!localApplicationOrigin(host.origin))||urlOrigin(url)!==host.origin||url.username||url.password||url.search||url.hash ||!/^[0-9a-f]{64}$/.test(asset.sha256)||!Number.isSafeInteger(asset.bytes)||asset.bytes<1 ||!Number.isSafeInteger(asset.maxBytes)||asset.maxBytes4*1024*1024)throw error("application_asset_binding"); diff --git a/runtime/packages/sdk/src/application/browser-selection.ts b/runtime/packages/sdk/src/application/browser-selection.ts index 0443dbfe0..5141bf303 100644 --- a/runtime/packages/sdk/src/application/browser-selection.ts +++ b/runtime/packages/sdk/src/application/browser-selection.ts @@ -43,7 +43,7 @@ export class ApplicationBrowserSelection { let base:URL,fallback:URL; try{base=new URL(origin);fallback=new URL(options.fallbackPath??"/",base);}catch{throw new ApplicationSelectionError("application_selection_binding");} const parameter=options.parameter??"collection"; - if(!isAppOrigin(origin,true)||urlOrigin(base)!==origin||urlOrigin(fallback)!==origin||fallback.username||fallback.password||fallback.search||fallback.hash|| + if(!isAppOrigin(origin)||urlOrigin(base)!==origin||urlOrigin(fallback)!==origin||fallback.username||fallback.password||fallback.search||fallback.hash|| typeof parameter!=="string"||! /^[a-z][a-zA-Z0-9_.-]{0,63}$/.test(parameter)||["code","state","error","error_description"].includes(parameter))throw new ApplicationSelectionError("application_selection_binding"); this.origin=origin;this.parameter=parameter;this.fallback=fallback.href;this.port=port; } diff --git a/runtime/packages/sdk/src/application/declaration.ts b/runtime/packages/sdk/src/application/declaration.ts index 53ba3c1d9..270261e8b 100644 --- a/runtime/packages/sdk/src/application/declaration.ts +++ b/runtime/packages/sdk/src/application/declaration.ts @@ -1,7 +1,7 @@ import {storageNamespaceDigest} from "./storage-scope.js"; import {appOriginPlatform} from "../app-host/app-origin.js"; import {mdbaseError} from "../errors.js"; -import {applicationEnvironmentAllowed, applicationLoopbackAllowed, localApplicationEnvironment, localApplicationOrigin} from "./local-build.js"; +import {applicationEnvironmentAllowed, localApplicationEnvironment, localApplicationOrigin} from "./local-build.js"; export type ApplicationJson=null|boolean|number|string|readonly ApplicationJson[]|{readonly[key:string]:ApplicationJson}; export interface ApplicationStorageDeclaration { @@ -82,7 +82,7 @@ export function applicationDeclaration(input:MdbaseApplicationDeclaration):Reado ||typeof name!=="string"||!name.trim()||new TextEncoder().encode(name).length>100||/[\u0000-\u001f\u007f]/.test(name) ||typeof origin!=="string"||typeof environment!=="string"||!applicationEnvironmentAllowed(environment)||!manifest||Array.isArray(manifest)||typeof manifest!=="object")fail(); if(localApplicationEnvironment(environment)&&!localApplicationOrigin(origin))fail(); - const platform=appOriginPlatform(origin,applicationLoopbackAllowed(environment),(manifest as Record).platform === "native" ? "native" : undefined); + const platform=appOriginPlatform(origin,(manifest as Record).platform === "native" ? "native" : undefined); const kind=data.installationKind??(platform==="mobile"?"mobile":"app-runtime"); if(platform===null||(kind!=="app-runtime"&&kind!=="mobile")||(platform==="mobile"&&kind!=="mobile")||((platform==="extension"||platform==="native")&&kind!=="app-runtime"))fail(); const override=data.storage;if(override!==undefined&&(!override||Array.isArray(override)||typeof override!=="object"||Object.keys(override).some(key=>!["joinDatabaseName","setupDatabaseName","unsyncedDatabaseName","applicationNamespace"].includes(key))))fail(); diff --git a/runtime/packages/sdk/src/application/entry.ts b/runtime/packages/sdk/src/application/entry.ts index a48f6b2e4..501302ea6 100644 --- a/runtime/packages/sdk/src/application/entry.ts +++ b/runtime/packages/sdk/src/application/entry.ts @@ -6,7 +6,7 @@ import {declaredReturnUrl} from "./return-url.js"; import {resolveApplicationRegistration} from "./registration.js"; import {packagedApplicationAssets} from "./packaged-assets.js"; import {appReleaseTrust} from "./packaged-trust.js"; -import {applicationLoopbackAllowed} from "./local-build.js"; +import {serviceLoopbackAllowed} from "./local-build.js"; import {selectAppEnvironment} from "../app-host/app-environment.js"; import type {ApplicationRuntime,MdbaseApplicationSnapshot as RuntimeSnapshot} from "./runtime.js"; import {ApplicationBrowserSelection,ApplicationSelectionError,browserApplicationSelectionPort} from "./browser-selection.js"; @@ -281,14 +281,14 @@ function unavailableApplication():MdbaseApplication{ return Object.freeze({getSnapshot:()=>snapshot,subscribe:()=>()=>{},start:refuse,authorize:refuse,select:refuse,approve:refuse,reauthorize:refuse,refreshCollectionList:refuse,requestAccess:refuse,chooseAnother:refuse, assess:refuse,configure:refuse,setup:Object.freeze({plan:refuse,apply:refuse}),clearSelection:refuse,forget:refuse,deleteCollection:refuse,setForeground(){throw unavailable();},close:refuse,signOut:refuse}); } -const browserHost=(origin:string)=>typeof Worker==="function"&&typeof MessageChannel==="function"&¤tAppOrigin()!==null&&isAppOrigin(origin,true); +const browserHost=(origin:string)=>typeof Worker==="function"&&typeof MessageChannel==="function"&¤tAppOrigin()!==null&&isAppOrigin(origin); function signInOpener(value:MdbaseSignInOpener|undefined,declaration:ApplicationDeclarationSnapshot):MdbaseSignInOpener|null{ if(value===undefined)return null; const fail=():never=>{throw mdbaseError("invalid_request","Invalid sign-in opener","application_sign_in_opener");}; if(!value||typeof value!=="object"||typeof value.open!=="function"||(value.close!==undefined&&typeof value.close!=="function"))return fail(); const returnUrl=value.returnUrl; if(returnUrl!==undefined){ - if(!declaredReturnUrl(returnUrl,declaration.manifest,applicationLoopbackAllowed(declaration.environment)))fail(); + if(!declaredReturnUrl(returnUrl,declaration.manifest))fail(); } const open=value.open.bind(value),close=value.close?.bind(value); return Object.freeze({open,...(close?{close}:{}),...(returnUrl!==undefined?{returnUrl}:{})}); @@ -626,7 +626,7 @@ export class MdbaseConnect implements MdbaseApplicationEntry { }); } private check():void{if(this.closed||this.hidden||this.lifetime.signal.aborted)throw mdbaseError("cancelled","Application entry fenced","application_entry_fenced");} - private environment(){return selectAppEnvironment({environment:this.declaration.environment,appOrigin:this.declaration.origin,platform:this.declaration.manifest.platform === "native" ? "native" : undefined,release:appReleaseTrust()});} + private environment(){return selectAppEnvironment({environment:this.declaration.environment,appOrigin:this.declaration.origin,release:appReleaseTrust()});} private initialize():Promise{ if(this.initialization)return this.initialization; const incarnation=this.incarnation; @@ -637,7 +637,7 @@ export class MdbaseConnect implements MdbaseApplicationEntry { if(typeof import.meta.url!=="string")throw unavailable(); const moduleUrl=new URL(import.meta.url); if(urlOrigin(moduleUrl)!==this.declaration.origin||currentAppOrigin()!==this.declaration.origin)throw mdbaseError("unavailable","Application module binding unavailable","application_worker_module_binding"); - const environment=this.environment();packagedApplicationAssets(this.declaration.environment,this.declaration.origin,this.declaration.manifest.platform === "native" ? "native" : undefined); + const environment=this.environment();packagedApplicationAssets(this.declaration.environment,this.declaration.origin); // A browser below the SDK's feature floor fails here, once, with a typed // problem — before any registration, Worker, key or storage allocation. const capabilities=await probeBrowserCapabilities();this.check(); @@ -700,7 +700,7 @@ export class MdbaseConnect implements MdbaseApplicationEntry { /** Reserve the SDK popup synchronously in the user gesture, before any await. */ private reservePortal(options:{readonly signal?:AbortSignal}){ this.check();const environment=this.environment(),signal=options.signal?AbortSignal.any([this.lifetime.signal,options.signal]):this.lifetime.signal; - const lab=applicationLoopbackAllowed(this.declaration.environment),current=()=>!this.closed; + const lab=serviceLoopbackAllowed(this.declaration.environment),current=()=>!this.closed; const host:ApplicationPortalHost=this.signIn?openerApplicationPortalHost(environment.cpOrigin,signal,current,this.signIn,lab):browserApplicationPortalHost(environment.cpOrigin,signal,current,lab); return {portal:reserveApplicationPortal(host),signal}; } diff --git a/runtime/packages/sdk/src/application/local-build.ts b/runtime/packages/sdk/src/application/local-build.ts index 42c80d613..decb85c11 100644 --- a/runtime/packages/sdk/src/application/local-build.ts +++ b/runtime/packages/sdk/src/application/local-build.ts @@ -1,5 +1,5 @@ import { LOCAL_APPLICATION_BUILD } from "../local-build.js"; -export { LOCAL_APPLICATION_BUILD, labLoopbackHttpOrigin, localApplicationHttpOrigin, localApplicationWebSocketUrl } from "../local-build.js"; +export { LOCAL_APPLICATION_BUILD, loopbackHttpOrigin, localApplicationHttpOrigin, localApplicationWebSocketUrl } from "../local-build.js"; export function localApplicationEnvironment(environment: string): boolean { return LOCAL_APPLICATION_BUILD && environment === "local"; @@ -9,7 +9,9 @@ export function applicationEnvironmentAllowed(environment: string): boolean { return LOCAL_APPLICATION_BUILD ? environment === "local" : environment === "lab" || environment === "staging" || environment === "production"; } -export function applicationLoopbackAllowed(environment: string): boolean { +/** Whether mdbase's own origins (control plane, log) may be loopback HTTP: LAB + * and local builds only. An application's own origin does not depend on it. */ +export function serviceLoopbackAllowed(environment: string): boolean { return LOCAL_APPLICATION_BUILD ? environment === "local" : environment === "lab"; } diff --git a/runtime/packages/sdk/src/application/manifest.ts b/runtime/packages/sdk/src/application/manifest.ts index b4dfe7118..6d144a81f 100644 --- a/runtime/packages/sdk/src/application/manifest.ts +++ b/runtime/packages/sdk/src/application/manifest.ts @@ -1,7 +1,6 @@ import {authorizationContractRequirements,authorizationFiles,authorizationOperations,semanticContractVersion} from "@mdbase-dev/connect-protocol"; import {parseVersionedAppManifest} from "@mdbase-dev/connect-protocol/manifest"; import {plainApplicationManifest,type ApplicationDeclarationSnapshot} from "./declaration.js"; -import {applicationLoopbackAllowed} from "./local-build.js"; function frozen(value:T):Readonly{ if(value!==null&&typeof value==="object"){ @@ -14,7 +13,7 @@ function frozen(value:T):Readonly{ * has no capability→operation table, parser, permission defaults, or selection * widening. Registration/native authority/catalog readiness remain separate. */ export function compileApplicationManifest(declaration:Readonly){ - const versioned=parseVersionedAppManifest(plainApplicationManifest(declaration),{allowLocal:applicationLoopbackAllowed(declaration.environment)}); + const versioned=parseVersionedAppManifest(plainApplicationManifest(declaration),{allowLocal:true}); const manifest=frozen(versioned.manifest),requirements=manifest.requirements; const semanticVersion=semanticContractVersion(requirements); const operations=frozen(authorizationOperations(manifest,{})); diff --git a/runtime/packages/sdk/src/application/native-assets.ts b/runtime/packages/sdk/src/application/native-assets.ts index 82fee7973..edb66378d 100644 --- a/runtime/packages/sdk/src/application/native-assets.ts +++ b/runtime/packages/sdk/src/application/native-assets.ts @@ -29,9 +29,8 @@ export function applicationNativeAssets( assetSha256:released.assetSha256,source:Object.freeze({...released.source}), trustedRoots:Object.freeze(released.trustedRoots.map(root=>new Uint8Array(root))),policyPins:new Uint8Array(released.policyPins), }); - const platform=declaration.manifest.platform === "native" ? "native" as const : undefined; - const environment=selectAppEnvironment({environment:declaration.environment,appOrigin:declaration.origin,platform,release}); - const host={origin:declaration.origin,platform,environment:declaration.environment,...(fetcher?{fetch:fetcher}:{})}; + const environment=selectAppEnvironment({environment:declaration.environment,appOrigin:declaration.origin,release}); + const host={origin:declaration.origin,environment:declaration.environment,...(fetcher?{fetch:fetcher}:{})}; const loadRuntime=applicationAssetLoader(packaged.runtime,host),loadSqlite=applicationAssetLoader(packaged.sqlite,host); const initialize=packaged.initializeSqlite; if(typeof initialize!=="function"||typeof handoff!=="function")throw new Error("SDK packaged native application assets unavailable"); @@ -41,7 +40,7 @@ export function applicationNativeAssets( joinDatabaseName:declaration.storage.joinDatabaseName, setupJournal:Object.freeze({databaseName:declaration.storage.setupDatabaseName,applicationNamespace:declaration.storage.applicationNamespace, appOrigin:declaration.origin,cpOrigin:environment.cpOrigin}), - sql,handoff,...(fetcher?{fetch:fetcher}:{}),allowLoopbackHttp:environment.allowLoopbackHttp,allowNativeLoopbackHttp:environment.allowNativeLoopbackHttp, + sql,handoff,...(fetcher?{fetch:fetcher}:{}),allowLoopbackHttp:environment.allowLoopbackHttp, }); // The unsynced collection: the same runtime and SQL, no sign-in or log. Its // setup journal is its own: sign-out erases the signed-in one, and the diff --git a/runtime/packages/sdk/src/application/packaged-assets.ts b/runtime/packages/sdk/src/application/packaged-assets.ts index 26e11bb30..2c8d49839 100644 --- a/runtime/packages/sdk/src/application/packaged-assets.ts +++ b/runtime/packages/sdk/src/application/packaged-assets.ts @@ -16,16 +16,19 @@ const unavailable = () => mdbaseError("unavailable", "SDK packaged application a /** Private SDK selector, not an application configuration callback. UI and * Worker use the same ESM artifact anchor. Selection performs no IO, storage, * Worker construction, dynamic imports or WASM initialization. */ -export function packagedApplicationAssets(environment:"lab"|"staging"|"production"|"local",appOrigin:string,platform?:"native"):PackagedApplicationNativeAssets { +export function packagedApplicationAssets(environment:"lab"|"staging"|"production"|"local",appOrigin:string):PackagedApplicationNativeAssets { if(!applicationEnvironmentAllowed(environment))throw unavailable(); const release=appReleaseTrust(); - selectAppEnvironment({environment,appOrigin,platform,release}); + selectAppEnvironment({environment,appOrigin,release}); // Literal `new URL(, import.meta.url)` forms let a consumer bundler // (Vite/Rollup/webpack) emit and rewrite the native assets. let runtimeUrl:URL,sqliteUrl:URL; try { + // A development server names this module with a cache-busting query + // (Vite's `?v=`). The assets resolve beside it without that query, and their + // bytes are checked against the pins, so the query is not part of the anchor. const module=new URL(import.meta.url); - if(urlOrigin(module)!==appOrigin||module.username||module.password||module.search||module.hash)throw unavailable(); + if(urlOrigin(module)!==appOrigin||module.username||module.password)throw unavailable(); runtimeUrl=new URL("../native/runtime.wasm",import.meta.url); sqliteUrl=new URL("../native/sqlite3.wasm",import.meta.url); if(urlOrigin(runtimeUrl)!==appOrigin||urlOrigin(sqliteUrl)!==appOrigin)throw unavailable(); diff --git a/runtime/packages/sdk/src/application/portal-reservation.ts b/runtime/packages/sdk/src/application/portal-reservation.ts index a8116df2c..7d5df25eb 100644 --- a/runtime/packages/sdk/src/application/portal-reservation.ts +++ b/runtime/packages/sdk/src/application/portal-reservation.ts @@ -1,4 +1,4 @@ -import { labLoopbackHttpOrigin } from "./local-build.js"; +import { loopbackHttpOrigin } from "./local-build.js"; import type {AppWebSignInPortal} from "../app-host/web-sign-in-session.js"; export class ApplicationPortalError extends Error { @@ -30,7 +30,7 @@ export function reserveApplicationPortal(host:ApplicationPortalHost):AppWebSignI }; check(); let cp:URL;try{cp=new URL(origin);}catch{throw new ApplicationPortalError("application_popup_binding");} - const loopback=host.allowLoopbackHttp===true&&labLoopbackHttpOrigin(origin); + const loopback=host.allowLoopbackHttp===true&&loopbackHttpOrigin(origin); if(cp.origin!==origin||cp.username||cp.password||cp.search||cp.hash||cp.pathname!=="/"||(cp.protocol!=="https:"&&!loopback))throw new ApplicationPortalError("application_popup_binding"); let window:ApplicationPopupWindow|null; try{window=open();}catch{throw new ApplicationPortalError("application_popup_unavailable");} diff --git a/runtime/packages/sdk/src/application/registration.ts b/runtime/packages/sdk/src/application/registration.ts index 384c5b5c0..bf4211b8f 100644 --- a/runtime/packages/sdk/src/application/registration.ts +++ b/runtime/packages/sdk/src/application/registration.ts @@ -12,7 +12,6 @@ import { type MdbaseApplicationDeclaration, } from "./declaration.js"; import { appReleaseTrust } from "./packaged-trust.js"; -import { applicationLoopbackAllowed } from "./local-build.js"; import { REGISTRATION_REFUSAL_MAX_BYTES, registrationRefusal, @@ -154,7 +153,6 @@ export async function resolveApplicationRegistration( environment = selectAppEnvironment({ environment: declaration.environment, appOrigin: declaration.origin, - platform: declaration.manifest.platform === "native" ? "native" : undefined, release: appReleaseTrust(), }); } catch { @@ -167,7 +165,7 @@ export async function resolveApplicationRegistration( } try { manifest = parseVersionedAppManifest(plainApplicationManifest(declaration), { - allowLocal: applicationLoopbackAllowed(declaration.environment), + allowLocal: true, }).manifest; } catch { throw binding(); @@ -347,7 +345,7 @@ export async function resolveApplicationRegistration( let returned: typeof manifest; try { returned = parseVersionedAppManifest(app.application_declaration, { - allowLocal: applicationLoopbackAllowed(declaration.environment), + allowLocal: true, }).manifest; } catch { throw badReply(); diff --git a/runtime/packages/sdk/src/application/return-url.ts b/runtime/packages/sdk/src/application/return-url.ts index 931a134d4..b69781c9e 100644 --- a/runtime/packages/sdk/src/application/return-url.ts +++ b/runtime/packages/sdk/src/application/return-url.ts @@ -1,23 +1,22 @@ -import {isNativeLoopbackOrigin} from "@mdbase-dev/connect-protocol/manifest"; /** The sign-in opener's `return_url` (RFC 8252 §7.1 / §8.1). It must exactly * equal one of the canonical manifest's `redirect_uris` (no prefix, pattern or - * normalisation match) and be either an https URL on the homepage origin, LAB - * loopback http, or a private-use reverse-DNS scheme derived from the manifest + * normalisation match) and be either an https or loopback http URL on the + * homepage origin, or a private-use reverse-DNS scheme derived from the manifest * ID (the same rule the CP applies at registration). It carries no credential: * sign-in still completes by the SDK's own pairing poll. */ -import { labLoopbackHttpOrigin } from "../application/local-build.js"; +import { loopbackHttpOrigin } from "../application/local-build.js"; const RESERVED = ["http", "https", "file", "javascript", "data", "blob", "about", "capacitor", "chrome-extension", "moz-extension"]; -export function declaredReturnUrl(value: unknown, manifest: unknown, loopbackHttp: boolean): boolean { +export function declaredReturnUrl(value: unknown, manifest: unknown): boolean { if (typeof value !== "string" || !value || value.length > 2048 || value.includes("*")) return false; const declared = manifest && typeof manifest === "object" ? (manifest as {redirect_uris?: unknown}).redirect_uris : undefined; if (!Array.isArray(declared) || !declared.includes(value)) return false; - const {id, homepage, platform} = manifest as {id?: unknown; homepage?: unknown; platform?: unknown}; + const {id, homepage} = manifest as {id?: unknown; homepage?: unknown}; let url: URL; try { url = new URL(value); } catch { return false; } if (url.username || url.password || url.hash) return false; if (url.protocol === "https:" || url.protocol === "http:") { let home: URL; try { home = new URL(String(homepage)); } catch { return false; } - return url.origin === home.origin && (url.protocol === "https:" || (platform === "native" && isNativeLoopbackOrigin(url.origin)) || (loopbackHttp && labLoopbackHttpOrigin(url.origin))); + return url.origin === home.origin && (url.protocol === "https:" || loopbackHttpOrigin(url.origin)); } const scheme = url.protocol.slice(0, -1); return typeof id === "string" && scheme.includes(".") && /^[a-z][a-z0-9+.-]*$/.test(scheme) && !RESERVED.includes(scheme) diff --git a/runtime/packages/sdk/src/application/runtime.ts b/runtime/packages/sdk/src/application/runtime.ts index ced132fd4..195d9ee82 100644 --- a/runtime/packages/sdk/src/application/runtime.ts +++ b/runtime/packages/sdk/src/application/runtime.ts @@ -6,7 +6,7 @@ import type {MdbaseApplicationConnection,MdbaseApplicationProblem,MdbaseApplicat import {pendingWrites,signOutPendingWritesError} from "./sign-out.js"; import {ApplicationScope} from "./scope.js"; import {ApplicationUiConnections} from "./ui-connections.js"; -import {applicationLoopbackAllowed} from "./local-build.js"; +import {serviceLoopbackAllowed} from "./local-build.js"; import {consentedFileActions} from "./file-access.js"; import type {MdbaseSetupPlan,MdbaseSetupPlanOptions} from "./setup.js"; import {ApplicationWorkerClient,ApplicationWorkerReplyError} from "./worker-client.js"; @@ -50,7 +50,7 @@ export class ApplicationRuntime { private snapshot:MdbaseApplicationSnapshot=Object.freeze({...this.raw,readiness:emptyReadiness()}); private pending:Promise|null=null;private closing:Promise|null=null;private epoch=0;private loggedProblem:string|null=null;private assessing=false;private retiring=false;private closed=false;private stopped=false;private stopFailed=false; constructor(input:ApplicationRuntimeActivation){ - this.cpOrigin=input.cpOrigin;this.appName=input.declaration.name;this.lab=applicationLoopbackAllowed(input.declaration.environment); + this.cpOrigin=input.cpOrigin;this.appName=input.declaration.name;this.lab=serviceLoopbackAllowed(input.declaration.environment); this.scope=new ApplicationScope(input.compiledManifest,{name:input.declaration.name,version:"1"},this.lifetime.signal,input.typeIssues,id=>consentedFileActions(this.raw,id)); this.worker=new ApplicationWorkerClient(input.port,input.binding,{ snapshot:value=>{if(!this.closed){this.raw=value;this.followSession();this.publish();}}, diff --git a/runtime/packages/sdk/src/application/worker-session.ts b/runtime/packages/sdk/src/application/worker-session.ts index d98fab40f..0b3b5fda7 100644 --- a/runtime/packages/sdk/src/application/worker-session.ts +++ b/runtime/packages/sdk/src/application/worker-session.ts @@ -43,7 +43,7 @@ export function applicationWorkerSession( cpOrigin:assets.environment.cpOrigin,environment:binding.environment, release:assets.release,signal,locks,loadRuntime:assets.loadRuntime, openPortal:portal,openCollection:createAppWebCollectionAdapter(collection), - autoSelect:"never",localDatabases:Object.freeze([collection.joinDatabaseName,collection.setupJournal.databaseName]),...(fetcher?{fetch:fetcher}:{}),allowLoopbackHttp:assets.environment.allowLoopbackHttp,allowNativeLoopbackHttp:assets.environment.allowNativeLoopbackHttp, + autoSelect:"never",localDatabases:Object.freeze([collection.joinDatabaseName,collection.setupJournal.databaseName]),...(fetcher?{fetch:fetcher}:{}),allowLoopbackHttp:assets.environment.allowLoopbackHttp, // Omit requestedCreateCollections: ORIGINAL stored request terms must not // be silently rewritten during restore. A fresh installation asks to create // collections so first-run approval can offer "New collection…" (a new diff --git a/runtime/packages/sdk/src/application/worker.ts b/runtime/packages/sdk/src/application/worker.ts index 487f8a84c..d6fb8b56d 100644 --- a/runtime/packages/sdk/src/application/worker.ts +++ b/runtime/packages/sdk/src/application/worker.ts @@ -26,7 +26,7 @@ async function initialize(event:MessageEvent):Promise { const storageNamespace=value.length===4?value[3]:null; if(storageNamespace!==null&&(typeof storageNamespace!=="string"||storageNamespace!==declaration.storage.applicationNamespace))throw new ApplicationWorkerError("application_worker_binding"); if(declaration.origin!==currentAppOrigin())throw new ApplicationWorkerError("application_worker_binding"); - const packaged=packagedApplicationAssets(declaration.environment,declaration.origin,declaration.manifest.platform === "native" ? "native" : undefined); + const packaged=packagedApplicationAssets(declaration.environment,declaration.origin); // All startup requests remain queued on the SAME unstarted MessagePort. // No profile, journal, key or native actor exists until this original // bootstrap has validated its immutable declaration and asset selection. diff --git a/runtime/packages/sdk/src/local-build.ts b/runtime/packages/sdk/src/local-build.ts index 59d085ce2..3f938f233 100644 --- a/runtime/packages/sdk/src/local-build.ts +++ b/runtime/packages/sdk/src/local-build.ts @@ -13,10 +13,13 @@ export function localApplicationHttpOrigin(value: string): boolean { } catch { return false; } } -/** LAB loopback HTTP admission, only behind a caller's explicit LAB flag; local - * builds narrow it to {@link localApplicationHttpOrigin}. Not the native - * exception, which is canonical 127.0.0.1 only (`isNativeLoopbackOrigin`). */ -export function labLoopbackHttpOrigin(value: string): boolean { +/** A loopback HTTP origin; local builds narrow it to {@link localApplicationHttpOrigin}. + * As an application's own origin it is admitted in every environment: it is + * where an application runs while it is developed. As one of mdbase's origins + * (control plane, log) it also needs the caller's LAB flag, see + * `serviceLoopbackAllowed`. Not the native exception, which is canonical + * 127.0.0.1 only (`isNativeLoopbackOrigin`). */ +export function loopbackHttpOrigin(value: string): boolean { if (LOCAL_APPLICATION_BUILD) return localApplicationHttpOrigin(value); try { const url = new URL(value); diff --git a/runtime/packages/sdk/test/app-device-key-provider.test.ts b/runtime/packages/sdk/test/app-device-key-provider.test.ts index 639ac79bf..0f98b8cbe 100644 --- a/runtime/packages/sdk/test/app-device-key-provider.test.ts +++ b/runtime/packages/sdk/test/app-device-key-provider.test.ts @@ -23,7 +23,7 @@ describe("first-party original-device browser KEK provider (actual WebCrypto, st const warm=await AppIndexedDbDeviceKeyProvider.open({...f.options,mode:"existing"}),b=warm.deviceCustodyKek({signal:f.options.signal});expect(Array.from(new Uint8Array(await crypto.subtle.decrypt({name:"AES-GCM",iv},b,cipher)))).toEqual([7,8]);expect(f.state.writes).toBe(1);expect(Object.keys(f.state.value as object).sort()).toEqual(["key","version"]);warm.close(); }); it.each(["installationId"] as const)("nil %s refuses before IO",async field=>{const f=await fixture();f.source[field]="00000000-0000-0000-0000-000000000000";await expect(AppIndexedDbDeviceKeyProvider.open(f.options)).rejects.toThrow("preserve storage");expect(f.open).not.toHaveBeenCalled();}); - it.each(["https://foreign.test","http://app.example.test"])("foreign/unapproved %s refuses before IO",async value=>{const f=await fixture();await expect(AppIndexedDbDeviceKeyProvider.open({...f.options,origin:value,allowLoopbackHttp:true})).rejects.toThrow("preserve storage");expect(f.open).not.toHaveBeenCalled();}); + it.each(["https://foreign.test","http://app.example.test"])("foreign/unapproved %s refuses before IO",async value=>{const f=await fixture();await expect(AppIndexedDbDeviceKeyProvider.open({...f.options,origin:value})).rejects.toThrow("preserve storage");expect(f.open).not.toHaveBeenCalled();}); it("aborted/non-owned scope and unavailable platform refuse before IO",async()=>{const f=await fixture(),controller=new AbortController();controller.abort();await expect(AppIndexedDbDeviceKeyProvider.open({...f.options,signal:controller.signal})).rejects.toThrow("preserve storage");f.source.installationOwned=()=>false;await expect(AppIndexedDbDeviceKeyProvider.open(f.options)).rejects.toThrow("preserve storage");f.source.installationOwned=()=>true;vi.stubGlobal("CryptoKey",undefined);await expect(AppIndexedDbDeviceKeyProvider.open(f.options)).rejects.toThrow("preserve storage");vi.stubGlobal("CryptoKey",f.sample.constructor);vi.stubGlobal("crypto",undefined);await expect(AppIndexedDbDeviceKeyProvider.open(f.options)).rejects.toThrow("preserve storage");expect(f.open).not.toHaveBeenCalled();}); it("missing/empty/corrupt existing never creates a substitute key",async()=>{const f=await fixture();await expect(AppIndexedDbDeviceKeyProvider.open({...f.options,mode:"existing"})).rejects.toThrow("preserve storage");expect(f.state.exists).toBe(false);f.state.exists=true;await expect(AppIndexedDbDeviceKeyProvider.open({...f.options,mode:"existing"})).rejects.toThrow("preserve storage");f.state.value={version:1,key:{extractable:false}};await expect(AppIndexedDbDeviceKeyProvider.open({...f.options,mode:"existing"})).rejects.toThrow("preserve storage");expect(f.state.writes).toBe(0);}); it("fresh-existing conflict and invalid stored key preserve the exact record",async()=>{const f=await fixture();f.state.exists=true;f.state.value={version:1,key:f.sample};await expect(AppIndexedDbDeviceKeyProvider.open(f.options)).rejects.toThrow("preserve storage");expect(f.state.writes).toBe(0);const bad=await crypto.subtle.generateKey({name:"AES-GCM",length:256},true,["encrypt","decrypt"]);f.state.value={version:1,key:bad};await expect(AppIndexedDbDeviceKeyProvider.open({...f.options,mode:"existing"})).rejects.toThrow("preserve storage");expect((f.state.value as {key:CryptoKey}).key.extractable).toBe(true);expect(f.state.writes).toBe(0);}); diff --git a/runtime/packages/sdk/test/app-environment.test.ts b/runtime/packages/sdk/test/app-environment.test.ts index dabdb4ddd..ee36f8ca6 100644 --- a/runtime/packages/sdk/test/app-environment.test.ts +++ b/runtime/packages/sdk/test/app-environment.test.ts @@ -15,7 +15,7 @@ describe("explicit build environment selection (context validation, not authenti }); it("selects immutable explicit LAB context and no production default", () => { const selected = selectAppEnvironment({environment: "lab", appOrigin, release: release()}); - expect(selected).toEqual({environment: "lab", appOrigin, cpOrigin, logOrigin: "https://log.example.test", assetSha256: "11".repeat(32), allowLoopbackHttp: true, allowNativeLoopbackHttp: false}); + expect(selected).toEqual({environment: "lab", appOrigin, cpOrigin, logOrigin: "https://log.example.test", assetSha256: "11".repeat(32), allowLoopbackHttp: true}); expect(Object.isFrozen(selected)).toBe(true); expect(() => selectAppEnvironment({environment: undefined as never, appOrigin, release: release()})).toThrow(); expect(() => selectAppEnvironment({environment: "production", appOrigin: undefined as never, release: release("production")})).toThrow(); @@ -27,10 +27,12 @@ describe("explicit build environment selection (context validation, not authenti const selected = selectAppEnvironment({environment: "lab", appOrigin: "https://isolated.example.test", release: release()}); expect(selected.allowLoopbackHttp).toBe(false); }); - it("refuses LAB/production release-tag swaps and production loopback", () => { + it("refuses LAB/production release-tag swaps; a production loopback app origin never relaxes CP/log HTTPS", () => { expect(() => selectAppEnvironment({environment: "production", appOrigin: "https://next.example.test", release: release()})).toThrow(); expect(() => selectAppEnvironment({environment: "lab", appOrigin, release: release("production")})).toThrow(); - expect(() => selectAppEnvironment({environment: "production", appOrigin, release: release("production")})).toThrow(); + expect(selectAppEnvironment({environment: "production", appOrigin, release: release("production")})).toMatchObject({appOrigin, allowLoopbackHttp: false}); + for (const field of ["cpOrigin", "logOrigin"]) + expect(() => selectAppEnvironment({environment: "production", appOrigin, release: {...release("production"), [field]: appOrigin}})).toThrow(); expect(selectAppEnvironment({environment: "production", appOrigin: "https://notes.example.test", release: release("production")}).allowLoopbackHttp).toBe(false); }); it.each(["lab", "staging"] as const)("production declarations refuse the %s package's fixed trust", environment => { @@ -48,7 +50,7 @@ describe("explicit build environment selection (context validation, not authenti it.each([["staging", "production"], ["production", "staging"]] as const)("refuses %s selection with a %s release", (environment, released) => { expect(() => selectAppEnvironment({environment, appOrigin: "https://notes.example.test", release: release(released)})).toThrow(); }); - it("selects distinct staging trust and rejects environment swaps and loopback before side effects", () => { + it("selects distinct staging trust, rejects environment swaps and admits only loopback HTTP app origins", () => { const bundled = {...release("staging"), cpOrigin: "https://connect-staging.mdbase.dev"}; const selected = selectAppEnvironment({environment: "staging", appOrigin: "https://notes-staging.example.test", release: bundled}); expect(selected).toMatchObject({environment: "staging", cpOrigin: bundled.cpOrigin, allowLoopbackHttp: false}); @@ -57,6 +59,8 @@ describe("explicit build environment selection (context validation, not authenti for (const environment of ["lab", "production", "local"]) expect(() => selectAppEnvironment({environment: "staging", appOrigin: "https://notes-staging.example.test", release: release(environment)})).toThrow(); for (const origin of ["http://127.0.0.1:48218", "http://localhost:48218", "http://[::1]:48218"]) + expect(selectAppEnvironment({environment: "staging", appOrigin: origin, release: bundled})).toMatchObject({appOrigin: origin, cpOrigin: bundled.cpOrigin, allowLoopbackHttp: false}); + for (const origin of ["http://notes-staging.example.test", "http://192.168.0.1:48218", "http://127.0.0.2:48218"]) expect(() => selectAppEnvironment({environment: "staging", appOrigin: origin, release: bundled})).toThrow(); }); it.each(["cpOrigin"] as const)("keeps %s HTTPS/canonical-origin requirements", field => { diff --git a/runtime/packages/sdk/test/app-noise-idb.test.ts b/runtime/packages/sdk/test/app-noise-idb.test.ts index c8ddf3fcc..351847541 100644 --- a/runtime/packages/sdk/test/app-noise-idb.test.ts +++ b/runtime/packages/sdk/test/app-noise-idb.test.ts @@ -11,7 +11,7 @@ function fixture() { afterEach(() => vi.unstubAllGlobals()); describe("opaque IndexedDB Noise store entry boundary", () => { it.each(["https://foreign.test", "not-an-origin"])("denies unapproved or non-origin %s before IO", async value => { - const f = fixture(); await expect(AppIndexedDbNoiseProtectedStore.open({ ...f.options, origin: value, allowLoopbackHttp: true })).rejects.toThrow("app noise custody store unavailable"); expect(f.open).not.toHaveBeenCalled(); + const f = fixture(); await expect(AppIndexedDbNoiseProtectedStore.open({ ...f.options, origin: value })).rejects.toThrow("app noise custody store unavailable"); expect(f.open).not.toHaveBeenCalled(); }); it.each(["installationId"] as const)("denies nil %s before IO", async key => { const f = fixture(); f.options.source[key] = "00000000-0000-0000-0000-000000000000"; await expect(AppIndexedDbNoiseProtectedStore.open(f.options)).rejects.toThrow("unavailable"); expect(f.open).not.toHaveBeenCalled(); diff --git a/runtime/packages/sdk/test/application-asset-loader.test.ts b/runtime/packages/sdk/test/application-asset-loader.test.ts index ef24737a0..4f1f31cc7 100644 --- a/runtime/packages/sdk/test/application-asset-loader.test.ts +++ b/runtime/packages/sdk/test/application-asset-loader.test.ts @@ -59,13 +59,15 @@ describe("SDK-private bundled asset intake (transport integrity, not release aut it.each(["https://foreign.test/runtime.wasm","http://app.test/runtime.wasm"])("refuses unbound source URL %s before fetch",async changed=>{ const fetcher=vi.fn(),a=await asset();expect(()=>applicationAssetLoader({...a,url:changed},{origin,environment:"production",fetch:fetcher})).toThrow("application asset");expect(fetcher).not.toHaveBeenCalled(); }); - it("staging assets reject HTTP loopback before fetch",async()=>{ + it.each(["staging","production"] as const)("%s assets load from a loopback development origin, no other HTTP origin",async environment=>{ const a=await asset(),fetcher=vi.fn(); for(const app of ["http://127.0.0.1:32111","http://localhost:32111","http://[::1]:32111"]) - expect(()=>applicationAssetLoader({...a,url:app+"/runtime.wasm"},{origin:app,environment:"staging",fetch:fetcher})).toThrow("application asset"); + expect(()=>applicationAssetLoader({...a,url:app+"/runtime.wasm"},{origin:app,environment,fetch:fetcher})).not.toThrow(); + for(const app of ["http://app.test","http://192.168.0.1:32111"]) + expect(()=>applicationAssetLoader({...a,url:app+"/runtime.wasm"},{origin:app,environment,fetch:fetcher})).toThrow("application asset"); expect(fetcher).not.toHaveBeenCalled(); }); - it("lab HTTP remains explicit loopback-only",async()=>{ + it("lab HTTP is loopback-only too",async()=>{ const a=await asset();expect(()=>applicationAssetLoader({...a,url:"http://127.0.0.1:32111/runtime.wasm"},{origin:"http://127.0.0.1:32111",environment:"lab"})).not.toThrow(); expect(()=>applicationAssetLoader({...a,url:"http://app.test/runtime.wasm"},{origin:"http://app.test",environment:"lab"})).toThrow("application asset"); }); diff --git a/runtime/packages/sdk/test/application-declaration.test.ts b/runtime/packages/sdk/test/application-declaration.test.ts index 5a8b13818..acb33fd15 100644 --- a/runtime/packages/sdk/test/application-declaration.test.ts +++ b/runtime/packages/sdk/test/application-declaration.test.ts @@ -20,14 +20,19 @@ describe("generic application declaration custody (not registration/trust/manife expect(copy.manifest.requirements).toEqual({access:"full_collection",contracts:[]});expect("capabilities" in (copy.manifest.requirements as object)).toBe(false); }); it.each(["http://tasknotes.dev","https://tasknotes.dev/path"])("refuses unsafe/nonexact production origin %s without normalization",origin=>{expect(()=>applicationDeclaration({...declaration(),origin})).toThrow("application declaration");}); - it("admits explicit staging without production relabelling or loopback HTTP",()=>{ + it.each(["staging","production"] as const)("admits a loopback development origin in %s, not another insecure origin",environment=>{ + for(const origin of ["http://localhost:5173","http://127.0.0.1:32111","http://[::1]:32111"]) + expect(applicationDeclaration({...declaration(),environment,origin})).toMatchObject({environment,origin,installationKind:"app-runtime"}); + for(const origin of ["http://foreign.test","http://192.168.0.1:5173","http://x.localhost:5173","http://localhost:5173/app"]) + expect(()=>applicationDeclaration({...declaration(),environment,origin})).toThrow("application declaration"); + }); + it("admits explicit staging without production relabelling",()=>{ expect(applicationDeclaration({...declaration(),environment:"staging",origin:"https://notes-staging.example.test"}).environment).toBe("staging"); - for(const origin of ["http://127.0.0.1:32111","http://localhost:32111","https://notes-staging.example.test/path"]) - expect(()=>applicationDeclaration({...declaration(),environment:"staging",origin})).toThrow("application declaration"); + expect(()=>applicationDeclaration({...declaration(),environment:"staging",origin:"https://notes-staging.example.test/path"})).toThrow("application declaration"); for(const environment of ["unknown","STAGING",""]) expect(()=>applicationDeclaration({...declaration(),environment:environment as never})).toThrow("application declaration"); }); - it("explicit lab only permits loopback HTTP, not arbitrary insecure origin",()=>{ + it("explicit lab permits loopback HTTP, not arbitrary insecure origin",()=>{ expect(applicationDeclaration({...declaration(),environment:"lab",origin:"http://127.0.0.1:32111"}).origin).toBe("http://127.0.0.1:32111"); expect(()=>applicationDeclaration({...declaration(),environment:"lab",origin:"http://foreign.test"})).toThrow("application declaration"); }); diff --git a/runtime/packages/sdk/test/application-native-platform.test.ts b/runtime/packages/sdk/test/application-native-platform.test.ts index 2638e8b85..fd92476c6 100644 --- a/runtime/packages/sdk/test/application-native-platform.test.ts +++ b/runtime/packages/sdk/test/application-native-platform.test.ts @@ -15,21 +15,17 @@ const declaration=(origin:string,platform:unknown="native")=>applicationDeclarat const release=():AppBundledReleaseTrust=>({schema:"mdbn-app-trust/release/1",environment:"production",cpOrigin:"https://cp.test",logOrigin:"https://log.test",assetSha256:"a".repeat(64),source:{repository:"mdbase-dev/mdbase-connect",commit:"b".repeat(40),version:"test.1"},trustedRoots:[new Uint8Array(32).fill(1)],policyPins:Uint8Array.of(1)}); afterEach(()=>vi.restoreAllMocks()); describe("declared native production loopback",()=>{ - it.each(["http://127.0.0.1","http://127.0.0.1:1","http://127.0.0.1:49152","http://127.0.0.1:65535"])("accepts %s only with the native declaration",origin=>{ - expect(appOriginPlatform(origin,false,"native")).toBe("native"); - expect(appOriginPlatform(origin)).toBeNull(); + it.each(["http://127.0.0.1","http://127.0.0.1:1","http://127.0.0.1:49152","http://127.0.0.1:65535"])("accepts %s as a declared native origin",origin=>{ + expect(appOriginPlatform(origin,"native")).toBe("native"); + expect(appOriginPlatform(origin)).toBe("web"); expect(compileApplicationManifest(declaration(origin)).manifest).toMatchObject({platform:"native",homepage:origin}); - expect(()=>declaration(origin,null)).toThrow(); - expect(selectAppEnvironment({environment:"production",appOrigin:origin,platform:"native",release:release()})).toMatchObject({allowLoopbackHttp:false,allowNativeLoopbackHttp:true}); - expect(()=>selectAppEnvironment({environment:"production",appOrigin:origin,release:release()})).toThrow(); - expect(declaredReturnUrl(`${origin}/callback`,manifest(origin),false)).toBe(true); - expect(declaredReturnUrl(`${origin}/other`,manifest(origin),false)).toBe(false); - expect(declaredReturnUrl(`${origin}/callback`,manifest(origin,null),false)).toBe(false); + expect(selectAppEnvironment({environment:"production",appOrigin:origin,release:release()})).toMatchObject({appOrigin:origin,allowLoopbackHttp:false}); + expect(declaredReturnUrl(`${origin}/callback`,manifest(origin))).toBe(true); + expect(declaredReturnUrl(`${origin}/other`,manifest(origin))).toBe(false); }); it.each(["http://localhost:49152","http://x.localhost:49152","http://127.0.0.2:49152","http://192.168.0.1:49152","http://[::1]:49152","http://127.1:49152","http://2130706433:49152","http://127.0.0.1.evil.test:49152","http://user@127.0.0.1:49152","http://127.0.0.1:0","http://127.0.0.1:65536","app://obsidian.md"])("refuses %s despite native claim",origin=>{ - expect(appOriginPlatform(origin,false,"native")).toBeNull(); + expect(appOriginPlatform(origin,"native")).toBeNull(); expect(()=>declaration(origin)).toThrow(); - expect(()=>selectAppEnvironment({environment:"production",appOrigin:origin,platform:"native",release:release()})).toThrow(); }); it.each(["https://remote.example.test","https://127.0.0.1:49152","http://localhost:49152","http://[::1]:49152"])("rejects a native manifest with %s in any origin-bearing field",other=>{ const origin="http://127.0.0.1:49152",base=manifest(origin); @@ -42,9 +38,9 @@ describe("declared native production loopback",()=>{ it("keeps CP/log HTTPS and exact return-URL ports despite native claim",()=>{ const origin="http://127.0.0.1:49152"; for(const field of ["cpOrigin","logOrigin"]) - expect(()=>selectAppEnvironment({environment:"production",appOrigin:origin,platform:"native",release:{...release(),[field]:origin}})).toThrow(); - expect(declaredReturnUrl("http://127.0.0.1:49153/callback",manifest(origin),false)).toBe(false); - expect(declaredReturnUrl("app://obsidian.md/callback",{...manifest(origin),redirect_uris:["app://obsidian.md/callback"]},false)).toBe(false); + expect(()=>selectAppEnvironment({environment:"production",appOrigin:origin,release:{...release(),[field]:origin}})).toThrow(); + expect(declaredReturnUrl("http://127.0.0.1:49153/callback",manifest(origin))).toBe(false); + expect(declaredReturnUrl("app://obsidian.md/callback",{...manifest(origin),redirect_uris:["app://obsidian.md/callback"]})).toBe(false); }); it("registers the canonical native declaration and keeps its current port binding",async()=>{ const origin="http://127.0.0.1:49152",input=declaration(origin),canonical=parseVersionedAppManifest(input.manifest).manifest; @@ -58,7 +54,7 @@ describe("declared native production loopback",()=>{ const origin="http://127.0.0.1:49152",bytes=Uint8Array.of(0,97,115,109),sha256=Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256",bytes)),x=>x.toString(16).padStart(2,"0")).join(""); const asset={url:`${origin}/runtime.wasm`,sha256,bytes:4,maxBytes:4},fetch=vi.fn(async()=>new Response(bytes)); const assets=applicationNativeAssets(declaration(origin),{release,runtime:asset,sqlite:asset,initializeSqlite:vi.fn()},vi.fn(),fetch); - expect(assets.environment.appOrigin).toBe(origin);expect(assets.collection.allowLoopbackHttp).toBe(false);expect(assets.collection.allowNativeLoopbackHttp).toBe(true); + expect(assets.environment.appOrigin).toBe(origin);expect(assets.collection.allowLoopbackHttp).toBe(false); expect(await assets.loadRuntime({signal:new AbortController().signal})).toEqual(bytes); expect(assets.collection.setupJournal.appOrigin).toBe(origin); }); diff --git a/runtime/packages/sdk/test/application-packaged-assets.test.ts b/runtime/packages/sdk/test/application-packaged-assets.test.ts index 17d312e76..dffe47050 100644 --- a/runtime/packages/sdk/test/application-packaged-assets.test.ts +++ b/runtime/packages/sdk/test/application-packaged-assets.test.ts @@ -47,6 +47,13 @@ describe("SDK packaged application assets",()=>{ expect(()=>select("lab","null")).toThrow(); } finally {vi.unstubAllGlobals();} }); + it("selects the assets beside a development server's versioned module URL",async()=>{ + const select=await selector("http://localhost:5173/node_modules/@mdbase-dev/sdk/dist/application/packaged-assets.js?v=ce183db8"); + const assets=select("lab","http://localhost:5173"); + expect(assets.runtime.url).toBe("http://localhost:5173/node_modules/@mdbase-dev/sdk/dist/native/runtime.wasm"); + expect(assets.sqlite.url).toBe("http://localhost:5173/node_modules/@mdbase-dev/sdk/dist/native/sqlite3.wasm"); + }); + it("refuses a module anchor that carries credentials",async()=>{const select=await selector("https://user:secret@app.test/sdk/application/packaged-assets.js");expect(()=>select("lab","https://app.test")).toThrow();}); it("refuses a foreign module anchor",async()=>{const select=await selector("https://foreign.test/sdk/application/a.js");expect(()=>select("lab","https://app.test")).toThrow();}); it("selector uses the canonical packaging pins without exposing build provenance or initializer metadata",async()=>{ const select=await selector("https://app.test/sdk/application/packaged-assets.js"); diff --git a/runtime/packages/sdk/test/local-transport-origins.test.ts b/runtime/packages/sdk/test/local-transport-origins.test.ts index 39a00a80d..88f784b29 100644 --- a/runtime/packages/sdk/test/local-transport-origins.test.ts +++ b/runtime/packages/sdk/test/local-transport-origins.test.ts @@ -41,7 +41,7 @@ it.each(cases)("$environment compiledLocal=$compiledLocal: $origin", async ({ co }); // Load one graph at a time: concurrent reset/mock imports can observe an // in-flight unmocked dependency before Vitest installs its compiled module. - const { labLoopbackHttpOrigin, localApplicationHttpOrigin } = await import("../src/local-build.js"); + const { loopbackHttpOrigin, localApplicationHttpOrigin } = await import("../src/local-build.js"); const { AppProtectedInstallationSignIn } = await import("../src/app-host/installation-sign-in.js"); const { AppCollectionSetupJournal } = await import("../src/app-host/collection-setup-journal.js"); const { controlRouteResolver } = await import("../src/transport/control-route.js"); @@ -49,7 +49,7 @@ it.each(cases)("$environment compiledLocal=$compiledLocal: $origin", async ({ co const accepted = origin.startsWith("https:") || (compiledLocal && localAccepted); expect(localApplicationHttpOrigin(origin)).toBe(compiledLocal && origin.startsWith("http:") && localAccepted); // A local build narrows the LAB flag to its own exact local origins. - expect(labLoopbackHttpOrigin(origin)).toBe(compiledLocal ? origin.startsWith("http:") && localAccepted : labAccepted); + expect(loopbackHttpOrigin(origin)).toBe(compiledLocal ? origin.startsWith("http:") && localAccepted : labAccepted); // Only URL admission is under test; this selected-scope stub is NOT custody, // consent, collection readiness or an end-to-end sign-in fixture. const flow = { confirmedSelection: () => ({}), state: { environment } } as unknown as typeof AppProtectedInstallationSignIn.prototype; diff --git a/runtime/packages/sdk/test/native-loopback-storage.test.ts b/runtime/packages/sdk/test/loopback-app-origin-storage.test.ts similarity index 73% rename from runtime/packages/sdk/test/native-loopback-storage.test.ts rename to runtime/packages/sdk/test/loopback-app-origin-storage.test.ts index e98b4e739..f484b123b 100644 --- a/runtime/packages/sdk/test/native-loopback-storage.test.ts +++ b/runtime/packages/sdk/test/loopback-app-origin-storage.test.ts @@ -31,7 +31,7 @@ async function browser(appOrigin=origin){ } const options=(appOrigin=origin)=>({origin:appOrigin,source,mode:"fresh" as const,signal:new AbortController().signal}); const stores=[ - ["installation",async(p:ReturnType&{allowLoopbackHttp?:boolean;allowNativeLoopbackHttp?:boolean})=>(await AppInstallationStore.open({...p,appId,cpOrigin,locks},()=>Uint8Array.of(1))).store], + ["installation",async(p:ReturnType&{allowLoopbackHttp?:boolean})=>(await AppInstallationStore.open({...p,appId,cpOrigin,locks},()=>Uint8Array.of(1))).store], ["device KEK",AppIndexedDbDeviceKeyProvider.open.bind(AppIndexedDbDeviceKeyProvider)], ["device keys",AppIndexedDbDeviceKeyProtectedStore.open.bind(AppIndexedDbDeviceKeyProtectedStore)], ["Noise",AppIndexedDbNoiseProtectedStore.open.bind(AppIndexedDbNoiseProtectedStore)], @@ -40,44 +40,39 @@ const stores=[ ] as const; afterEach(()=>{vi.restoreAllMocks();vi.unstubAllGlobals();}); -it.each(stores)("%s storage admits native IPv4 without the LAB flag",async(_name,open)=>{ - await browser();const p=options(); - await expect(open(p)).rejects.toBeDefined(); - const store=await open({...p,allowNativeLoopbackHttp:true});await store.close(); -}); -it.each(stores)("%s storage refuses other native HTTP hosts before IDB",async(_name,open)=>{ - for(const other of ["http://localhost:49152","http://[::1]:49152","http://127.0.0.2:49152","http://127.1:49152","http://evil.test:49152"]){ - await browser(other);const spy=vi.spyOn(indexedDB,"open"); - await expect(open({...options(other),allowNativeLoopbackHttp:true})).rejects.toBeDefined();expect(spy).not.toHaveBeenCalled(); +it.each(stores)("%s storage admits a loopback app origin without the LAB flag",async(_name,open)=>{ + for(const app of [origin,"http://localhost:49152","http://[::1]:49152"]){ + await browser(app);const store=await open(options(app));await store.close(); } }); -it.each(stores)("%s storage keeps explicit LAB localhost and IPv6 support",async(_name,open)=>{ - for(const other of ["http://localhost:49152","http://[::1]:49152"]){ - await browser(other);const store=await open({...options(other),allowLoopbackHttp:true});await store.close(); +it.each(stores)("%s storage refuses other HTTP hosts before IDB",async(_name,open)=>{ + for(const other of ["http://127.0.0.2:49152","http://127.1:49152","http://192.168.0.1:49152","http://x.localhost:49152","http://evil.test:49152"]){ + await browser(other);const spy=vi.spyOn(indexedDB,"open"); + await expect(open(options(other))).rejects.toBeDefined();expect(spy).not.toHaveBeenCalled(); } }); -it("native storage permission does not permit HTTP control-plane origins",async()=>{ +it("a loopback app origin does not permit an HTTP control-plane origin",async()=>{ await browser();const spy=vi.spyOn(indexedDB,"open"); - await expect(AppInstallationStore.open({...options(),cpOrigin:origin,appId,locks,allowNativeLoopbackHttp:true},()=>Uint8Array.of(1))).rejects.toBeDefined(); + await expect(AppInstallationStore.open({...options(),cpOrigin:origin,appId,locks},()=>Uint8Array.of(1))).rejects.toBeDefined(); expect(spy).not.toHaveBeenCalled(); }); -it("native selection cannot enable the LAB flag or use a copied selection",async()=>{ +it("a loopback production selection cannot enable the LAB flag or use a copied selection",async()=>{ await browser();const spy=vi.spyOn(indexedDB,"open"); - const selection=selectAppEnvironment({environment:"production",appOrigin:origin,platform:"native",release}); + const selection=selectAppEnvironment({environment:"production",appOrigin:origin,release}); const p={origin,cpOrigin,appId,appName:"Notes",environment:"production" as const,mode:"fresh" as const,signal:new AbortController().signal,locks, - allowNativeLoopbackHttp:true,environmentSelection:selection}; + environmentSelection:selection}; await expect(AppProtectedInstallationSignIn.open({...p,allowLoopbackHttp:true})).rejects.toThrow("binding"); await expect(AppProtectedInstallationSignIn.open({...p,environmentSelection:{...selection}})).rejects.toThrow("binding"); expect(spy).not.toHaveBeenCalled(); }); -it("native session opens and restores the original ledger without network or LAB trust",async()=>{ +it("a loopback production session opens and restores the original ledger without network or LAB trust",async()=>{ await browser();const fetch=vi.fn(); const p={origin,cpOrigin,appId,appName:"Notes",environment:"production" as const,release,signal:new AbortController().signal,locks, - allowNativeLoopbackHttp:true,loadRuntime:vi.fn(),openPortal:vi.fn(),openCollection:vi.fn(),fetch}; + loadRuntime:vi.fn(),openPortal:vi.fn(),openCollection:vi.fn(),fetch}; const session=new AppWebSignInSession(p),first=await session.start();expect(first.status).toBe("signed_out");await session.close(); const restored=new AppWebSignInSession(p);expect((await restored.start()).signIn).toEqual(first.signIn);await restored.close(); expect(fetch).not.toHaveBeenCalled();expect(p.loadRuntime).not.toHaveBeenCalled(); for(const field of ["cpOrigin","logOrigin"]) - expect(()=>selectAppEnvironment({environment:"production",appOrigin:origin,platform:"native",release:{...release,[field]:origin}})).toThrow(); + expect(()=>selectAppEnvironment({environment:"production",appOrigin:origin,release:{...release,[field]:origin}})).toThrow(); expect(()=>new AppWebSignInSession({...p,environment:"test" as never,release:{...release,environment:"test"}})).toThrow(); }); diff --git a/scripts/dev-environment.mjs b/scripts/dev-environment.mjs index 8691d3469..1bd253643 100644 --- a/scripts/dev-environment.mjs +++ b/scripts/dev-environment.mjs @@ -22,7 +22,6 @@ const environment = await createConnectEnvironment({ build: true, disposable: false, randomizeCredentials: false, - allowLocalApps: true, environment: { PUBLIC_URL: origins.publicUrl, MDBASE_CONNECT_ENVIRONMENT: "local", diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index 9e9b3ca32..b2d35f5af 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -44,7 +44,6 @@ const database = await createDatabase("memory"); const { app } = await buildApp({ db: database, devAuth: true, - allowInsecureManifests: true, portalDist: join(repoRoot, "apps", "portal", "dist"), publicUrl: serverUrl }); diff --git a/scripts/hosted-provider-e2e.mjs b/scripts/hosted-provider-e2e.mjs index f069c4e34..0c0c77ae6 100644 --- a/scripts/hosted-provider-e2e.mjs +++ b/scripts/hosted-provider-e2e.mjs @@ -1702,8 +1702,7 @@ schema: publicUrl: controlUrl, editorOrigin: localEditor.origin, managementOrigins: [localEditor.origin], - portalDist: join(repoRoot, "apps", "portal", "dist"), - allowInsecureManifests: true + portalDist: join(repoRoot, "apps", "portal", "dist") })); await controlApp.listen({ host: "127.0.0.1", port: controlPort }); const login = await rawRequest(controlUrl, "/v1/dev/session", { diff --git a/scripts/lib/connect-environment.mjs b/scripts/lib/connect-environment.mjs index dfded3011..e720da2c0 100644 --- a/scripts/lib/connect-environment.mjs +++ b/scripts/lib/connect-environment.mjs @@ -69,9 +69,6 @@ export async function createConnectEnvironment(options = {}) { ? { MDBASE_CONNECT_SERVER_IMAGE: options.serverImage } : {}), ...(options.natsImage ? { MDBASE_CONNECT_NATS_IMAGE: options.natsImage } : {}), - ...(options.allowLocalApps - ? { MDBASE_CONNECT_ALLOW_INSECURE_MANIFESTS: "1" } - : {}), ...(options.editorOrigin ? { MDBASE_EDITOR_ORIGIN: options.editorOrigin, diff --git a/scripts/stress/systems.mjs b/scripts/stress/systems.mjs index a04f69636..30bf7989b 100644 --- a/scripts/stress/systems.mjs +++ b/scripts/stress/systems.mjs @@ -56,7 +56,6 @@ async function createHttpSystem({ collectionId: _collectionId }) { devAuth: true, hostedCollections: true, hostedReferenceAuthority: true, - allowInsecureManifests: true, publicUrl: serverUrl }); app = built.app; diff --git a/scripts/sync-e2e.mjs b/scripts/sync-e2e.mjs index 010cccc62..2b93af007 100644 --- a/scripts/sync-e2e.mjs +++ b/scripts/sync-e2e.mjs @@ -21,7 +21,6 @@ const { app } = await buildApp({ devAuth: true, hostedCollections: true, hostedReferenceAuthority: true, - allowInsecureManifests: true, publicUrl: `http://127.0.0.1:${port}` }); await app.listen({ host: "127.0.0.1", port }); diff --git a/services/server/src/app.test.ts b/services/server/src/app.test.ts index bc815e5d5..1c4c5bde2 100644 --- a/services/server/src/app.test.ts +++ b/services/server/src/app.test.ts @@ -844,8 +844,7 @@ describe("mdbase connect server", () => { const { app } = await buildApp({ db, devAuth: true, - publicUrl: "http://connect.test", - allowInsecureManifests: true + publicUrl: "http://connect.test" }); resources.push(() => app.close()); @@ -2703,8 +2702,7 @@ describe("mdbase connect server", () => { devAuth: true, hostedCollections: true, hostedProvider, - publicUrl: "http://connect.test", - allowInsecureManifests: true + publicUrl: "http://connect.test" }); resources.push(() => app.close()); // Settle the seeded installation application's startup scan before arranging @@ -2809,8 +2807,7 @@ describe("mdbase connect server", () => { devAuth: true, hostedCollections: true, hostedProvider, - publicUrl: "http://connect.test", - allowInsecureManifests: true + publicUrl: "http://connect.test" }); resources.push(() => app.close()); @@ -3101,8 +3098,7 @@ describe("mdbase connect server", () => { devAuth: true, hostedCollections: true, hostedProvider, - publicUrl: "http://connect.test", - allowInsecureManifests: true + publicUrl: "http://connect.test" }); resources.push(() => app.close()); const session = await app.inject({ diff --git a/services/server/src/app.ts b/services/server/src/app.ts index 80e0fa619..b597482af 100644 --- a/services/server/src/app.ts +++ b/services/server/src/app.ts @@ -141,7 +141,6 @@ interface BuildOptions { /** Validated by runtime configuration; loopback tests default to publicUrl. */ identityIssuer?: string; portalDist?: string; - allowInsecureManifests?: boolean; trustProxy?: boolean; relayBroker?: RelayBroker; /** mdbase-next control plane; absent unless MDBASE_NEXT_CONTROL_PLANE=1. */ @@ -699,10 +698,7 @@ export async function buildApp(options: BuildOptions) { }); } registerConnectorRelayRoute(app, { db: options.db, relay }); - registerApplicationRoutes(app, { - db: options.db, - allowInsecureManifests: options.allowInsecureManifests - }); + registerApplicationRoutes(app, { db: options.db }); registerAccountOverviewRoute(app, { db: options.db, relay, diff --git a/services/server/src/features/applications/routes.ts b/services/server/src/features/applications/routes.ts index 5531c89ee..1fa5ac7de 100644 --- a/services/server/src/features/applications/routes.ts +++ b/services/server/src/features/applications/routes.ts @@ -7,7 +7,6 @@ import { upsertApplication } from "./store.js"; interface ApplicationRouteOptions { db: DatabasePool; - allowInsecureManifests?: boolean; } export function registerApplicationRoutes( @@ -16,10 +15,7 @@ export function registerApplicationRoutes( ): void { app.post("/v1/apps/validate", async (request) => { const input = z.object({ manifest: z.unknown() }).strict().parse(request.body); - const validated = registerApplicationManifest( - input.manifest, - options.allowInsecureManifests - ); + const validated = registerApplicationManifest(input.manifest); return { valid: true, declaration: { @@ -32,10 +28,7 @@ export function registerApplicationRoutes( app.post("/v1/apps/register", async (request) => { const input = z.object({ manifest: z.unknown() }).strict().parse(request.body); - const registered = registerApplicationManifest( - input.manifest, - options.allowInsecureManifests - ); + const registered = registerApplicationManifest(input.manifest); // Discovery and its first durable job are atomic. ON CONFLICT DO NOTHING is // intentional: re-registering an immutable exact application must not wake // a completed scan or disturb an in-flight lease/cursor. diff --git a/services/server/src/features/applications/sdk-registration.postgres.test.ts b/services/server/src/features/applications/sdk-registration.postgres.test.ts index 1ea235137..3220f2096 100644 --- a/services/server/src/features/applications/sdk-registration.postgres.test.ts +++ b/services/server/src/features/applications/sdk-registration.postgres.test.ts @@ -63,7 +63,7 @@ describePg("SDK application registration against the register route", () => { url.searchParams.set("options", `-csearch_path=${schema}`); db = await createDatabase(url.toString()); registerErrorHandler(app); - registerApplicationRoutes(app, { db, allowInsecureManifests: true }); + registerApplicationRoutes(app, { db }); }, 60_000); afterAll(async () => { await app.close(); await db?.end(); diff --git a/services/server/src/features/authorizations/fresh-application-issuance.test.ts b/services/server/src/features/authorizations/fresh-application-issuance.test.ts index cc9ffc429..93df1e953 100644 --- a/services/server/src/features/authorizations/fresh-application-issuance.test.ts +++ b/services/server/src/features/authorizations/fresh-application-issuance.test.ts @@ -62,7 +62,7 @@ describe("server fresh application issuance policy", () => { it("enabling v2 does not allow unsigned direct grant creation", async () => { const db = await createDatabase("memory"); resources.push(() => db.end()); - const { app } = await buildApp({ db, devAuth: true, publicUrl: "http://connect.test", allowInsecureManifests: true }); + const { app } = await buildApp({ db, devAuth: true, publicUrl: "http://connect.test" }); resources.push(() => app.close()); const session = await app.inject({ method: "POST", url: "/v1/dev/session", payload: { name: "Owner", email: "issuance@example.test" } }); const setCookie = session.headers["set-cookie"]!; diff --git a/services/server/src/features/authorizations/live-authorization.test.ts b/services/server/src/features/authorizations/live-authorization.test.ts index 4089d8650..9effc644f 100644 --- a/services/server/src/features/authorizations/live-authorization.test.ts +++ b/services/server/src/features/authorizations/live-authorization.test.ts @@ -33,8 +33,7 @@ describe("live connector-mediated authorization", () => { const { app, relay } = await buildApp({ db, devAuth: true, - publicUrl: "http://connect.test", - allowInsecureManifests: true + publicUrl: "http://connect.test" }); resources.push(() => app.close()); diff --git a/services/server/src/features/connectors/installation-app.test.ts b/services/server/src/features/connectors/installation-app.test.ts index e961459f3..f77935dc0 100644 --- a/services/server/src/features/connectors/installation-app.test.ts +++ b/services/server/src/features/connectors/installation-app.test.ts @@ -30,6 +30,11 @@ describe("registered installation origins", () => { it.each(["http://localhost:48218", "http://127.0.0.1:48219", "http://127.0.0.1", "https://127.0.0.1:48218", "http://127.0.0.1:48218/", "http://127.0.0.1:48218/path", "http://127.0.0.2:48218", "null", undefined])("refuses missing/changed host, port, scheme or URL form %s", async origin => { await expect(installationApp(registry().db, "lab", id, origin, "app-runtime")).rejects.toMatchObject({code: "installation_app_not_allowed"}); }); + it.each(["production", "staging"])("a loopback origin that is only listed for %s never pairs there", async environment => { + const {db} = registry({[environment]: {"app-runtime": [local, "http://localhost:5173"]}}); + for (const origin of [local, "http://localhost:5173"]) + await expect(installationApp(db, environment, id, origin, "app-runtime")).rejects.toMatchObject({code: "installation_app_not_allowed", status: 403}); + }); it("uses separate exact registered production/staging policies, without fallback", async () => { const {db} = registry(); expect((await installationApp(db, "production", id, "https://notes.example.test", "app-runtime")).origin).toBe("https://notes.example.test"); @@ -78,6 +83,24 @@ describe("self-registered application pairing origins", () => { .rejects.toMatchObject({ code: "installation_app_not_allowed", status: 403 }); } finally { await db.end(); } }); + it.each(["production", "staging"])("pairs an application under development from the loopback origin its manifest declares in %s", async environment => { + const db = await createDatabase("memory"); + try { + const dev = "http://localhost:5173"; + const published = await upsertApplication(db, registerApplicationManifest(journal)); + const app = await upsertApplication(db, registerApplicationManifest({ ...journal, homepage: dev, redirect_uris: [`${dev}/callback`] })); + expect(app.id).not.toBe(published.id); + expect(app.canonical_identity).not.toBe(published.canonical_identity); + expect(await installationApp(db, environment, app.id, dev, "app-runtime")).toEqual({ id: app.id, origin: dev, name: "Journal" }); + for (const origin of ["http://localhost:5174", "http://127.0.0.1:5173", "http://[::1]:5173", "https://localhost:5173", "http://localhost.example.test:5173", "http://192.168.0.1:5173"]) + await expect(installationApp(db, environment, app.id, origin, "app-runtime")).rejects.toMatchObject({ code: "installation_app_not_allowed", status: 403 }); + await expect(installationApp(db, environment, app.id, dev, "mobile")).rejects.toMatchObject({ code: "installation_app_not_allowed" }); + await expect(installationApp(db, environment, published.id, dev, "app-runtime")).rejects.toMatchObject({ code: "installation_app_not_allowed" }); + } finally { await db.end(); } + }); + it.each(["http://remote.example.test/", "http://192.168.0.1:5173/", "http://localhost.example.test/"])("refuses to register the insecure non-loopback homepage %s", homepage => { + expect(() => registerApplicationManifest({ ...journal, homepage, redirect_uris: [`${homepage}callback`] })).toThrow(); + }); it("assigns a different registered identity when a lookalike changes the declaration origin", async () => { const db = await createDatabase("memory"); try { diff --git a/services/server/src/features/connectors/installation-pairing.postgres.test.ts b/services/server/src/features/connectors/installation-pairing.postgres.test.ts index 99c9cc024..6f9b09749 100644 --- a/services/server/src/features/connectors/installation-pairing.postgres.test.ts +++ b/services/server/src/features/connectors/installation-pairing.postgres.test.ts @@ -81,7 +81,7 @@ describePg("installation device sign-in on daemon pairing", () => { db = await createDatabase(url.toString()); await app.register(cookie); registerErrorHandler(app); - registerApplicationRoutes(app,{db,allowInsecureManifests:true}); + registerApplicationRoutes(app,{db}); registerAccountOverviewRoute(app,{db,relay:new RelayHub(db),publicUrl:"https://connect.test",authenticationPolicy:new AuthenticationPolicyStore(db,"open")}); registerConnectorPairingRoutes(app, { db, publicUrl: "https://connect.test", installationDevices: true, installationEnvironment: "lab", relay:{pushPolicy:async(connector)=>{ const snapshot=await buildPolicySnapshot(db,connector,55_000,undefined,()=>true,"lease_v1",true,true,true) as LeasePolicySnapshot; diff --git a/services/server/src/features/connectors/installation-pairing.ts b/services/server/src/features/connectors/installation-pairing.ts index 01f24ad7e..cb971fc7e 100644 --- a/services/server/src/features/connectors/installation-pairing.ts +++ b/services/server/src/features/connectors/installation-pairing.ts @@ -1,4 +1,4 @@ -import { isNativeLoopbackOrigin } from "@mdbase-dev/connect-protocol/manifest"; +import { isLoopbackHttpOrigin, isNativeLoopbackOrigin } from "@mdbase-dev/connect-protocol/manifest"; /** Installation devices extend daemon pairing, never application OAuth grants. * Public account selection precedes key custody; attestation precedes explicit * device approval. Original request/tuple and outcome are immutable. */ @@ -60,7 +60,11 @@ export async function installationApp(db: DatabaseQueryable, environment: string return declared.origin !== "null" && declared.origin === origin; }); const nativeOrigin = declaration && "platform" in declaration && declaration.platform === "native" && kind === "app-runtime" && isNativeLoopbackOrigin(origin); - if (["staging", "production"].includes(environment) && origin.startsWith("http:") && !nativeOrigin) return fail("installation_app_not_allowed", 403); + // An application under development runs in a browser on the loopback origin + // its own manifest declares. No other plain-HTTP origin pairs in staging or + // production, including a loopback origin that is only listed for the environment. + const developmentOrigin = declaredOrigin && kind === "app-runtime" && isLoopbackHttpOrigin(origin); + if (["staging", "production"].includes(environment) && origin.startsWith("http:") && !nativeOrigin && !developmentOrigin) return fail("installation_app_not_allowed", 403); if (!declaredOrigin && !allowed.includes(origin) && !nativeOrigin) return fail("installation_app_not_allowed", 403); if (!app.name || !app.family_identity) throw new Error("Invalid registered installation identity."); const timerDeclaration = installationTimerDeclaration(app, { environment: environment as InstallationTimerBinding["environment"], app_origin: origin, kind }, environment); diff --git a/services/server/src/index.ts b/services/server/src/index.ts index c41f612e0..e26a2ae85 100644 --- a/services/server/src/index.ts +++ b/services/server/src/index.ts @@ -66,7 +66,6 @@ const { app } = await buildApp({ ? new HostedProviderClient(runtime.hostedProvider) : undefined, trustProxy: runtime.trustProxy, - allowInsecureManifests: process.env.MDBASE_CONNECT_ALLOW_INSECURE_MANIFESTS === "1", relayBroker, nextControlPlane: runtime.nextControlPlane ?? undefined, notifications: runtime.vapid || runtime.fcm || runtime.webhookSigning diff --git a/services/server/src/manifest.ts b/services/server/src/manifest.ts index dcff28f7b..411e05a24 100644 --- a/services/server/src/manifest.ts +++ b/services/server/src/manifest.ts @@ -34,12 +34,11 @@ export class ApplicationManifestError extends Error { * The canonical validator is shared with public developer tooling so a * declaration cannot pass supported CI and fail a matching Connect server. */ -export function registerApplicationManifest( - value: unknown, - allowInsecure = false -): RegisteredApplicationManifest { +export function registerApplicationManifest(value: unknown): RegisteredApplicationManifest { try { - const declaration = parseVersionedAppManifest(value, { allowLocal: allowInsecure }); + // A loopback HTTP homepage is how an application is developed. It registers + // as its own application, apart from the HTTPS one that is published. + const declaration = parseVersionedAppManifest(value, { allowLocal: true }); const { manifest } = declaration; const digest = canonicalSha256(manifest).slice("sha256:".length); return { diff --git a/test/upgrade/server-from-previous b/test/upgrade/server-from-previous index 5fb16f38a..d4922fed7 100755 --- a/test/upgrade/server-from-previous +++ b/test/upgrade/server-from-previous @@ -284,7 +284,6 @@ docker run --detach --name mdbase-connect-upgrade-candidate \ --env PORT="$UPGRADE_SERVER_PORT" \ --env MDBASE_CONNECT_DEV_AUTH=1 \ --env MDBASE_NEW_ACCOUNT_BACKEND=legacy \ - --env MDBASE_CONNECT_ALLOW_INSECURE_MANIFESTS=1 \ "$CANDIDATE_IMAGE" >/dev/null if ! upgrade_wait_http \ "$UPGRADE_SERVER_URL/ready" 'candidate server'; then From bc89ad601b1dedbb0610fb6428a2676c8996d061 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 10 Nov 2026 18:57:36 +1100 Subject: [PATCH 2/2] test(provider): expect the local-app approval identity for loopback test apps The provider system suite registers its applications on loopback origins. The approval screen now presents every loopback application as an unverified app on this computer, so assert that heading and the note naming the app. --- scripts/hosted-provider-e2e.mjs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/hosted-provider-e2e.mjs b/scripts/hosted-provider-e2e.mjs index 0c0c77ae6..005690d34 100644 --- a/scripts/hosted-provider-e2e.mjs +++ b/scripts/hosted-provider-e2e.mjs @@ -4384,6 +4384,14 @@ async function countMarkdownFiles(root) { return count; } +// This suite's applications run on loopback origins, which the approval screen +// presents as an unverified app on this computer, never under the manifest's name. +async function expectLocalApplicationIdentity(page, name) { + await expect(page.getByRole("heading", { name: "Connect an app", level: 1, exact: true })).toBeVisible(); + await expect(page.getByText("an app running on this computer", { exact: true })).toBeVisible(); + await expect(page.getByText(`This app calls itself ${name}.`)).toBeVisible(); +} + async function authorizeHostedApplication(authorizationUrl, cookie, collectionId, redirectUri) { const browser = await chromium.launch({ headless: true }); try { @@ -4397,7 +4405,7 @@ async function authorizeHostedApplication(authorizationUrl, cookie, collectionId }]); const page = await context.newPage(); await page.goto(authorizationUrl); - await expect(page.getByRole("heading", { name: "Hosted SDK E2E", level: 1, exact: true })).toBeVisible(); + await expectLocalApplicationIdentity(page, "Hosted SDK E2E"); await page.locator(".approval-form").waitFor(); const collection = page.getByRole("option", { name: "Hosted writing", exact: true @@ -4442,7 +4450,7 @@ async function authorizeHostedApplicationByCreating(authorizationUrl, cookie, re }]); const page = await context.newPage(); await page.goto(authorizationUrl); - await expect(page.getByRole("heading", { name: "Workout Inline E2E", level: 1, exact: true })).toBeVisible(); + await expectLocalApplicationIdentity(page, "Workout Inline E2E"); await expect(page.getByText("No compatible collection is ready.")).toBeVisible(); await expect(page.getByRole("combobox", { name: "Choose a collection", exact: true })).toHaveCount(0); await page.getByRole("button", { name: "New collection" }).click();