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..005690d34 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", { @@ -4385,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 { @@ -4398,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 @@ -4443,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(); 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