diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 4efd6884..3595e3b9 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -87,6 +87,13 @@ jobs:
echo "::error::PolkaVM functional specs must have 0 failures"
exit 1
fi
+ UPGRADE_EXECUTED=$(jq '[.suites[] | recurse(.suites[]?) | .specs[]? | select(.file == "host-upgrade.spec.ts") | select(any(.tests[]?.results[]?; .status != "skipped"))] | length' tests/functional/results.json)
+ UPGRADE_FAILED=$(jq '[.suites[] | recurse(.suites[]?) | .specs[]? | select(.file == "host-upgrade.spec.ts" and .ok == false)] | length' tests/functional/results.json)
+ UPGRADE_SKIPPED=$(jq '[.suites[] | recurse(.suites[]?) | .specs[]? | select(.file == "host-upgrade.spec.ts") | .tests[]?.results[]? | select(.status == "skipped")] | length' tests/functional/results.json)
+ if [ "$UPGRADE_EXECUTED" -lt 2 ] || [ "$UPGRADE_FAILED" -gt 0 ] || [ "$UPGRADE_SKIPPED" -gt 0 ]; then
+ echo "::error::host-upgrade.spec.ts must execute its upgrade scenarios without failures or skips (executed: $UPGRADE_EXECUTED, failed: $UPGRADE_FAILED, skipped: $UPGRADE_SKIPPED)"
+ exit 1
+ fi
LOADING_FAILED=$(jq '[.suites[] | recurse(.suites[]?) | .specs[]? | select(.file == "loading.spec.ts" and .ok == false)] | length' tests/functional/results.json)
RESOLUTION_FAILED=$(jq '[.suites[] | recurse(.suites[]?) | .specs[]? | select(.file == "resolution.spec.ts" and .ok == false)] | length' tests/functional/results.json)
TRANSPORT_FAILED=$(jq '[.suites[] | recurse(.suites[]?) | .specs[]? | select(.file == "network-transport.spec.ts" and .ok == false)] | length' tests/functional/results.json)
diff --git a/README.md b/README.md
index 0c542d49..6b2063f7 100644
--- a/README.md
+++ b/README.md
@@ -150,6 +150,26 @@ traffic stays on the bounded PolkaVM runtime ABI 1. Guest Host requests use the
Host-frame bytes use the canonical TrUAPI wire codec, currently version 3. Build guest clients against the SDK recorded
in `vendor/truapi-host.lock.json`; runtime ABI 1 compatibility alone does not imply TrUAPI wire compatibility.
+On this branch, `vendor/truapi-host.lock.json` pins the canonical SDK and Wasm to `feat/jam-peer-transport-on-seity`.
+JAM peer transport is execution-local in the sandbox. Before dialing a network, it requests `JamPeers` permission
+through the product's authenticated port to the shared page core. The host's Solid permission dialog shows the full
+genesis hash and offers **Allow once**, **Always allow**, and **Deny**; dismissal saves no decision. Durable decisions
+are scoped to product and genesis, while a one-time grant lasts only for that execution. This grants no account,
+signing, storage, or arbitrary web access.
+
+The sandbox checks for the required browser WebTransport capability before requesting permission. If it is unavailable,
+the host leaves the stored permission unchanged, shows the detected browser version and compatibility requirements, and
+the app can continue with its verified snapshot. Supported versions are Chrome or Edge 100+, Firefox 125+, and
+Safari/iOS 26.4+.
+
+The canonical session uses WebTransport to validators, with at most eight connections, sixteen streams per connection,
+and 1 MiB messages. Received data remains unverified until the guest checks it. The runtime menu's **Network access**
+section lists this execution's grants. Network updates continue while its display/audio menu is paused. Stop,
+replacement, and runtime failure close the session and refuse outstanding permission requests; a replacement guest
+cannot consume old replies. Ordinary host frames retain their 1 MiB bound and still use the shared page core; only peer
+frames use the larger bound needed for message framing. The session's ten-second dial deadline includes the permission
+prompt: a late decision does not resurrect an expired dial, though a retry can use the remembered decision.
+
App manifest v2 uses runtime ABI 1 with framebuffer, Tri2D, WebGPU Raster, and bounded capability negotiation; TrUAPI,
MotionSample v1, text, IME, focus, and wheel input use the same pinned browser runtime as native Hosts. UI output v1
applies cursor and IME-agent state in the sandbox. Clipboard text and HTTP(S) navigation cross an origin-checked parent
@@ -226,6 +246,17 @@ current page was loaded:
If a background re-resolution finds the on-chain CID has changed, dotli shows a **New version available** notification
with a **Reload** action rather than swapping content silently.
+The host PWA caches its shell separately from the cross-origin sandbox. On a worker update, open host pages report their
+sandbox-contract version. Matching hosts keep the normal update prompt. Legacy, incompatible, or nonresponsive hosts are
+reloaded at the same URL after the new shell finishes installing, without clearing wallet/app storage or product caches.
+This lets an already cached host recover even when it cannot understand the newer sandbox's update request; the
+sandbox's strict contract validation remains unchanged. As with any host reload, in-memory app state and credentialless
+iframe storage restart; persistent host storage is retained.
+
+Astro builds the release-specific classic upgrade worker after its static pages and before Workbox emits `host-sw.js`.
+Browser validation and Node-loaded build configuration share the version in
+`packages/config/src/host-sandbox-version.ts`; the build does not import browser network configuration.
+
## TrUAPI bridge
Loaded SPAs communicate with dotli through a postMessage-based protocol. The bridge exposes:
diff --git a/apps/host/astro.config.ts b/apps/host/astro.config.ts
index c100a629..2a7a9dad 100644
--- a/apps/host/astro.config.ts
+++ b/apps/host/astro.config.ts
@@ -9,7 +9,7 @@
import { sentryVitePlugin } from '@sentry/vite-plugin';
import { defineConfig } from 'astro/config';
import type { AstroIntegration } from 'astro';
-import type { Plugin, PluginOption } from 'vite';
+import { build as viteBuild, type Plugin, type PluginOption } from 'vite';
import { readFileSync, readdirSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { execSync } from 'node:child_process';
@@ -21,6 +21,7 @@ import { astroPwa } from '@config/vite/astro-pwa';
import { buildInfo, readPackageVersion } from '@config/vite/build-info';
import { appBuildOptions, rolldownOptions } from '@config/vite/build-options';
import { runtimeNetworkConfigScript } from '@config/vite/runtime-network-config';
+import { SANDBOX_SCHEMA_VERSION } from '../../packages/config/src/host-sandbox-version.ts';
import { stripAnalytics } from '@dotli/metrics/vite';
import { handleNodeIdentityProxy, IDENTITY_PROXY_PREFIX } from '../../scripts/identity-proxy.ts';
@@ -48,6 +49,38 @@ if ((process.env['VITE_COMMIT_SHA'] ?? '') === '') {
const OUT_DIR = 'dist';
const APP_URL = process.env['VITE_APP_URL'] ?? '';
+const HOST_UPDATE_SCRIPT = `assets/host-update-${process.env['VITE_COMMIT_SHA'] ?? 'dev'}.js`;
+
+function hostUpdateWorker(): AstroIntegration {
+ return {
+ name: 'host-update-worker',
+ hooks: {
+ // Finish before astroPwa generates the importing service worker.
+ // A release-specific URL avoids stale HTTP-cached importScripts.
+ 'astro:build:done': async ({ dir }) => {
+ await viteBuild({
+ configFile: false,
+ define: {
+ __HOST_SANDBOX_SCHEMA_VERSION__: JSON.stringify(SANDBOX_SCHEMA_VERSION),
+ },
+ build: {
+ emptyOutDir: false,
+ outDir: fileURLToPath(dir),
+ lib: {
+ entry: resolve(import.meta.dirname, 'src/host-update.ts'),
+ formats: ['iife'],
+ name: 'DotliHostUpdate',
+ fileName: () => HOST_UPDATE_SCRIPT,
+ },
+ sourcemap: false,
+ minify: true,
+ },
+ logLevel: 'warn',
+ });
+ },
+ },
+ };
+}
/**
* Walk every workspace member's `package.json` and collect its direct
@@ -286,11 +319,12 @@ export default defineConfig({
astroSolid(),
// Before astroPwa: it rewrites the page that the precache manifest hashes.
pagePreloads(),
+ hostUpdateWorker(),
// Host shell PWA. Scope-locked to the host origin (myapp.dot.li). The
// protocol iframe on host.dot.li and the app iframe on *.app.dot.li are
- // cross-origin and outside this SW's reach by design. `registerType:
- // "prompt"` defers update activation to the user via workbox-window in
- // src/pwa.ts.
+ // cross-origin and outside this SW's reach by design. Compatible host
+ // sessions keep prompt-style updates. The imported upgrade worker replaces
+ // incompatible cached shells without touching wallet or application storage.
astroPwa({
injectRegister: false,
registerType: 'prompt',
@@ -319,10 +353,11 @@ export default defineConfig({
// The TrUAPI core loads its ring-VRF module (~4.6 MB) only when a
// ring-VRF operation first needs it. Precaching it would make every
// installed shell download it after each release.
- globIgnores: ['**/truapi_provider_bg*.wasm', '**/truapi_verifiable_bg*.wasm'],
+ globIgnores: ['**/truapi_provider_bg*.wasm', '**/truapi_verifiable_bg*.wasm', '**/host-update-*.js'],
cleanupOutdatedCaches: true,
- // skipWaiting/clientsClaim stay false: prompt-style updates require
- // the waiting SW to sit idle until the user opts in.
+ importScripts: [HOST_UPDATE_SCRIPT],
+ // The upgrade worker overrides these only for outdated shells;
+ // matching-contract sessions still opt into an ordinary update.
skipWaiting: false,
clientsClaim: false,
maximumFileSizeToCacheInBytes: 32 * 1024 * 1024,
diff --git a/apps/host/package.json b/apps/host/package.json
index d6382502..7c21d42f 100644
--- a/apps/host/package.json
+++ b/apps/host/package.json
@@ -13,7 +13,7 @@
"build": "astro build",
"build:prod": "astro check && astro build && node ../../scripts/compress-dist.ts",
"test": "vitest run",
- "test:functional": "playwright test --config=tests/functional/playwright.config.ts tests/functional/loading.spec.ts tests/functional/resolution.spec.ts tests/functional/navigation.spec.ts tests/functional/network-transport.spec.ts tests/functional/ui-smoke.spec.ts tests/functional/host-settings.spec.ts tests/functional/polkavm.spec.ts tests/functional/chinpokomon.spec.ts",
+ "test:functional": "playwright test --config=tests/functional/playwright.config.ts tests/functional/loading.spec.ts tests/functional/resolution.spec.ts tests/functional/navigation.spec.ts tests/functional/network-transport.spec.ts tests/functional/ui-smoke.spec.ts tests/functional/host-settings.spec.ts tests/functional/polkavm.spec.ts tests/functional/chinpokomon.spec.ts tests/functional/host-upgrade.spec.ts",
"test:e2e": "playwright test --config=tests/e2e/playwright.config.ts",
"test:e2e:local": "E2E_PRODUCT_URL=${E2E_PRODUCT_URL:-http://localhost:5199} SIGNING_HOST_NETWORK=${SIGNING_HOST_NETWORK:-paseo-next-v2} playwright test --config=tests/e2e/playwright.config.ts",
"test:perf": "PERF_RUNS=10 PERF_DOMAIN_A=browse PERF_DOMAIN_B=host-playground playwright test --config=tests/performance/playwright.config.ts tests/performance/cold-start.spec.ts",
diff --git a/apps/host/src/host-update.ts b/apps/host/src/host-update.ts
new file mode 100644
index 00000000..5bbb484a
--- /dev/null
+++ b/apps/host/src/host-update.ts
@@ -0,0 +1,96 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+///
+declare const self: ServiceWorkerGlobalScope;
+
+declare const __HOST_SANDBOX_SCHEMA_VERSION__: number;
+
+export {};
+
+const VERSION_REPLY_TIMEOUT_MS = 1_500;
+
+function hasCurrentContract(client: WindowClient): Promise {
+ const { promise, resolve } = Promise.withResolvers();
+ const channel = new MessageChannel();
+ const finish = (matches: boolean): void => {
+ clearTimeout(timer);
+ channel.port1.onmessage = null;
+ channel.port1.close();
+ resolve(matches);
+ };
+ const timer = setTimeout(() => {
+ finish(false);
+ }, VERSION_REPLY_TIMEOUT_MS);
+ channel.port1.onmessage = (event: MessageEvent) => {
+ const data = event.data;
+ finish(
+ typeof data === 'object' &&
+ data !== null &&
+ 'version' in data &&
+ data.version === __HOST_SANDBOX_SCHEMA_VERSION__,
+ );
+ };
+ try {
+ client.postMessage({ type: 'dotli:host-contract-version' }, [channel.port2]);
+ } catch {
+ // The document can disappear while the worker is checking open tabs.
+ channel.port2.close();
+ finish(false);
+ }
+ return promise;
+}
+
+async function outdatedClients(): Promise {
+ const windows = (await self.clients.matchAll({ type: 'window', includeUncontrolled: true })).filter(client =>
+ client.url.startsWith(self.registration.scope),
+ );
+ const compatible = await Promise.all(windows.map(hasCurrentContract));
+ return windows.filter((_, index) => compatible[index] !== true);
+}
+
+function isWindowClient(client: Client | undefined): client is WindowClient {
+ return client?.type === 'window';
+}
+
+self.addEventListener('install', (event: ExtendableEvent) => {
+ if (self.registration.active === null) {
+ return;
+ }
+ event.waitUntil(
+ outdatedClients().then(async clients => {
+ // Compatible sessions retain the normal update prompt. Legacy hosts do
+ // not answer this query and cannot be relied on to handle a new sandbox's
+ // update request. Activate only after Workbox's precache install succeeds.
+ if (clients.length > 0) {
+ await self.skipWaiting();
+ }
+ }),
+ );
+});
+
+self.addEventListener('activate', (event: ExtendableEvent) => {
+ event.waitUntil(
+ (async () => {
+ // Probe again: the worker can restart between installation and activation,
+ // and a tab may have navigated while the new assets were downloading.
+ const clients = await outdatedClients();
+ if (clients.length === 0) {
+ return;
+ }
+ await self.clients.claim();
+ await Promise.all(
+ clients.map(async client => {
+ const current = await self.clients.get(client.id);
+ if (isWindowClient(current)) {
+ // Navigation fetches wait for activation. Awaiting them here would
+ // deadlock the worker and every tab it just claimed.
+ void current.navigate(current.url).catch((error: unknown) => {
+ console.warn('[dot.li] Could not reload an outdated host', error);
+ });
+ }
+ }),
+ );
+ })(),
+ );
+});
diff --git a/apps/host/src/pwa.ts b/apps/host/src/pwa.ts
index 9589a3fb..4c48ddcb 100644
--- a/apps/host/src/pwa.ts
+++ b/apps/host/src/pwa.ts
@@ -16,11 +16,27 @@
import { Workbox } from 'workbox-window';
import { showNotification } from '@dotli/ui';
import { log } from '@dotli/shared';
+import { SANDBOX_SCHEMA_VERSION } from '@dotli/config';
const UPDATE_INTERVAL_MS = 15 * 60 * 1000;
if ('serviceWorker' in navigator) {
const wb = new Workbox('/host-sw.js');
+ navigator.serviceWorker.addEventListener('message', (event: MessageEvent) => {
+ const data = event.data;
+ const reply = event.ports.at(0);
+ if (
+ typeof data !== 'object' ||
+ data === null ||
+ !('type' in data) ||
+ data.type !== 'dotli:host-contract-version' ||
+ reply === undefined
+ ) {
+ return;
+ }
+ reply.postMessage({ version: SANDBOX_SCHEMA_VERSION });
+ reply.close();
+ });
let hostUpdateRequired = false;
let applyingUpdate = false;
diff --git a/apps/host/tests/functional/chinpokomon.spec.ts b/apps/host/tests/functional/chinpokomon.spec.ts
index 670f2a8f..a0007df1 100644
--- a/apps/host/tests/functional/chinpokomon.spec.ts
+++ b/apps/host/tests/functional/chinpokomon.spec.ts
@@ -15,7 +15,7 @@ import { expect, test, type Page } from '@playwright/test';
import { readFile, readdir } from 'node:fs/promises';
import { join, relative } from 'node:path';
// Node-loaded specs use the side-effect-free contract, not the browser config barrel.
-import { SANDBOX_SCHEMA_VERSION } from '../../../../packages/config/src/host-sandbox-contract.js';
+import { SANDBOX_SCHEMA_VERSION } from '../../../../packages/config/src/host-sandbox-version.js';
import { archiveCar, installTruapiPortResponder } from './helpers/polkavm.js';
const bundleDir = process.env['DOTLI_CHINPOKOMON_BUNDLE'];
diff --git a/apps/host/tests/functional/host-upgrade.spec.ts b/apps/host/tests/functional/host-upgrade.spec.ts
new file mode 100644
index 00000000..97dad09e
--- /dev/null
+++ b/apps/host/tests/functional/host-upgrade.spec.ts
@@ -0,0 +1,343 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { test, expect, type Page } from '@playwright/test';
+import { createServer } from 'node:http';
+import { readFile } from 'node:fs/promises';
+import { extname, resolve, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const DIST = fileURLToPath(new URL('../../dist/', import.meta.url));
+const LEGACY_HTML = `
+Cached contract-v4 host
+`;
+
+// This deliberately has neither the new contract-query responder nor a
+// controllerchange reload handler: already-cached hosts cannot gain either.
+const LEGACY_WORKER = `
+self.addEventListener('install', event => {
+ event.waitUntil(caches.open('legacy-host-shell').then(cache => cache.add('/')));
+});
+self.addEventListener('activate', event => {
+ event.waitUntil(self.clients.claim());
+});
+self.addEventListener('fetch', event => {
+ if (event.request.mode === 'navigate') {
+ event.respondWith(caches.open('legacy-host-shell').then(cache => cache.match('/')));
+ }
+});
+`;
+
+const MIME: Record = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'text/javascript',
+ '.css': 'text/css',
+ '.json': 'application/json',
+ '.webmanifest': 'application/manifest+json',
+ '.wasm': 'application/wasm',
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+ '.ico': 'image/x-icon',
+};
+
+interface UpgradeFixture {
+ origin: string;
+ publishHtml(): void;
+ publishWorker(): void;
+ close(): Promise;
+}
+
+async function serveUpgrade(initial: 'legacy' | 'current'): Promise {
+ // Fail before launching the fixture if the production build is missing.
+ const [html, worker] = await Promise.all([
+ readFile(resolve(DIST, 'index.html')),
+ readFile(resolve(DIST, 'host-sw.js'), 'utf8'),
+ ]);
+ let currentHtml = initial === 'current';
+ let currentWorker = initial === 'current';
+ let release = 0;
+ const server = createServer((request, response) => {
+ void (async () => {
+ const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
+ response.setHeader('Cache-Control', 'no-store');
+ response.setHeader('Service-Worker-Allowed', '/');
+ if (pathname === '/host-sw.js') {
+ response.setHeader('Content-Type', 'text/javascript');
+ // A byte-only release marker creates a genuine browser SW update
+ // without copying, patching, or substituting the generated worker.
+ response.end(currentWorker ? `${worker}\n// test release ${String(release)}\n` : LEGACY_WORKER);
+ return;
+ }
+ if (pathname === '/' || pathname === '/index.html') {
+ response.setHeader('Content-Type', 'text/html; charset=utf-8');
+ response.end(currentHtml ? html : LEGACY_HTML);
+ return;
+ }
+ if (pathname === '/dotli-network.js') {
+ response.setHeader('Content-Type', 'text/javascript');
+ response.end('window.__DOTLI_NETWORK__ = {};\n');
+ return;
+ }
+ const path = resolve(DIST, `.${decodeURIComponent(pathname)}`);
+ if (!path.startsWith(`${resolve(DIST)}${sep}`)) {
+ response.writeHead(403).end();
+ return;
+ }
+ const bytes = await readFile(path);
+ response.setHeader('Content-Type', MIME[extname(path)] ?? 'application/octet-stream');
+ response.end(bytes);
+ })().catch((error: unknown) => {
+ response.writeHead(404).end(String(error));
+ });
+ });
+ const listening = Promise.withResolvers();
+ server.once('error', (error: Error) => {
+ listening.reject(error);
+ });
+ server.listen(0, '127.0.0.1', () => {
+ listening.resolve(undefined);
+ });
+ await listening.promise;
+ const address = server.address();
+ if (address === null || typeof address === 'string') {
+ throw new Error('Expected an ephemeral TCP listener');
+ }
+ return {
+ origin: `http://localhost:${String(address.port)}`,
+ publishHtml(): void {
+ currentHtml = true;
+ },
+ publishWorker(): void {
+ currentWorker = true;
+ release += 1;
+ },
+ async close(): Promise {
+ const closed = Promise.withResolvers();
+ server.close(error => {
+ if (error !== undefined) {
+ closed.reject(error);
+ } else {
+ closed.resolve(undefined);
+ }
+ });
+ server.closeAllConnections();
+ await closed.promise;
+ },
+ };
+}
+
+async function seedUserData(page: Page): Promise {
+ await page.evaluate(async () => {
+ localStorage.setItem('dotli-theme', 'dark');
+ localStorage.setItem('upgrade-user-setting', 'keep my settings');
+ const opened = Promise.withResolvers();
+ const request = indexedDB.open('upgrade-user-data', 1);
+ request.onupgradeneeded = () => request.result.createObjectStore('records');
+ request.onsuccess = () => {
+ opened.resolve(request.result);
+ };
+ request.onerror = () => {
+ opened.reject(new Error('Could not open upgrade user data', { cause: request.error }));
+ };
+ const db = await opened.promise;
+ const written = Promise.withResolvers();
+ const transaction = db.transaction('records', 'readwrite');
+ transaction.objectStore('records').put({ message: 'keep my saved record' }, 'saved');
+ transaction.oncomplete = () => {
+ written.resolve(undefined);
+ };
+ transaction.onerror = () => {
+ written.reject(new Error('Could not save upgrade user data', { cause: transaction.error }));
+ };
+ transaction.onabort = () => {
+ written.reject(new Error('Saving upgrade user data was aborted', { cause: transaction.error }));
+ };
+ await written.promise;
+ db.close();
+ const cache = await caches.open('upgrade-product-data');
+ await cache.put('/saved-product', new Response('keep my product cache'));
+ });
+}
+
+async function expectUserData(page: Page): Promise {
+ const saved = await page.evaluate(async () => {
+ const opened = Promise.withResolvers();
+ const openRequest = indexedDB.open('upgrade-user-data', 1);
+ openRequest.onsuccess = () => {
+ opened.resolve(openRequest.result);
+ };
+ openRequest.onerror = () => {
+ opened.reject(new Error('Could not open saved upgrade data', { cause: openRequest.error }));
+ };
+ const db = await opened.promise;
+ try {
+ const savedRecord = Promise.withResolvers();
+ const request = db.transaction('records').objectStore('records').get('saved');
+ request.onsuccess = () => {
+ savedRecord.resolve(request.result);
+ };
+ request.onerror = () => {
+ savedRecord.reject(new Error('Could not read saved upgrade data', { cause: request.error }));
+ };
+ const record = await savedRecord.promise;
+ return {
+ theme: localStorage.getItem('dotli-theme'),
+ setting: localStorage.getItem('upgrade-user-setting'),
+ record,
+ product: await (await caches.match('/saved-product'))?.text(),
+ };
+ } finally {
+ db.close();
+ }
+ });
+ expect(saved).toEqual({
+ theme: 'dark',
+ setting: 'keep my settings',
+ record: { message: 'keep my saved record' },
+ product: 'keep my product cache',
+ });
+}
+
+async function updateWorker(page: Page): Promise {
+ await page.evaluate(async () => {
+ const registration = await navigator.serviceWorker.ready;
+ // Return before activation can replace this execution context.
+ void registration.update();
+ });
+}
+
+test.describe('host service worker contract upgrades', () => {
+ test.setTimeout(60_000);
+
+ test('replaces a cache-first legacy host automatically without erasing user data', async ({ browser }) => {
+ const fixture = await serveUpgrade('legacy');
+ const context = await browser.newContext({ serviceWorkers: 'allow' });
+ try {
+ // Real landing code, but no external chain, protocol host, or analytics
+ // dependency. SW precache requests are served by the isolated HTTP server.
+ await context.route('**/*', route => {
+ return new URL(route.request().url()).origin === fixture.origin ? route.continue() : route.abort();
+ });
+ const page = await context.newPage();
+ const url = `${fixture.origin}/?upgrade=legacy#saved-location`;
+ await page.goto(url);
+ await page.waitForFunction(() => navigator.serviceWorker.controller !== null);
+ await expect(page.locator('#legacy-host')).toBeVisible();
+ await seedUserData(page);
+
+ // New HTML on the network alone cannot repair a cache-first old host.
+ // Keep the old worker endpoint until this ordinary reload proves it.
+ fixture.publishHtml();
+ const oldReload = await page.reload();
+ expect(oldReload?.fromServiceWorker()).toBe(true);
+ await expect(page.locator('#legacy-host')).toBeVisible();
+ await expectUserData(page);
+ let navigations = 0;
+ page.on('framenavigated', frame => {
+ if (frame === page.mainFrame()) {
+ navigations += 1;
+ }
+ });
+
+ fixture.publishWorker();
+ await updateWorker(page);
+ await expect(page.locator('.landing')).toBeVisible({ timeout: 20_000 });
+ await expect(page.locator('#legacy-host')).toHaveCount(0);
+ expect(page.url()).toBe(url);
+ expect(navigations).toBe(1);
+ await expect
+ .poll(() =>
+ page.evaluate(async () => {
+ const registration = await navigator.serviceWorker.ready;
+ return {
+ active: registration.active?.state,
+ waiting: registration.waiting !== null,
+ controlled: navigator.serviceWorker.controller !== null,
+ };
+ }),
+ )
+ .toEqual({ active: 'activated', waiting: false, controlled: true });
+ await expectUserData(page);
+ } finally {
+ await context.close();
+ await fixture.close();
+ }
+ });
+
+ test('keeps a compatible update waiting until the user applies it', async ({ browser }) => {
+ const fixture = await serveUpgrade('current');
+ const context = await browser.newContext({ serviceWorkers: 'allow' });
+ try {
+ await context.route('**/*', route => {
+ return new URL(route.request().url()).origin === fixture.origin ? route.continue() : route.abort();
+ });
+ const page = await context.newPage();
+ let navigations = 0;
+ page.on('framenavigated', frame => {
+ if (frame === page.mainFrame()) {
+ navigations += 1;
+ }
+ });
+ await page.goto(fixture.origin);
+ await page.evaluate(() => navigator.serviceWorker.ready.then(() => undefined));
+ await expect(page.locator('.landing')).toBeVisible();
+ expect(navigations).toBe(1); // Fresh installation must not reload the page.
+ await page.reload(); // Begin the update with a controlled, current host.
+ await page.waitForFunction(() => navigator.serviceWorker.controller !== null);
+ await seedUserData(page);
+ const beforeUpdate = navigations;
+ await page.evaluate(() => {
+ document.documentElement.dataset['contractQueries'] = '0';
+ navigator.serviceWorker.addEventListener('message', (event: MessageEvent) => {
+ const data = event.data;
+ if (
+ typeof data === 'object' &&
+ data !== null &&
+ 'type' in data &&
+ data.type === 'dotli:host-contract-version'
+ ) {
+ const root = document.documentElement;
+ root.dataset['contractQueries'] = String(Number(root.dataset['contractQueries']) + 1);
+ }
+ });
+ });
+
+ fixture.publishWorker();
+ await updateWorker(page);
+ const reload = page.getByRole('button', { name: 'Reload', exact: true });
+ await expect(reload).toBeVisible({ timeout: 20_000 });
+ await expect
+ .poll(() => page.evaluate(() => Number(document.documentElement.dataset['contractQueries'])))
+ .toBeGreaterThan(0);
+ // Observe longer than the contract-query timeout. A momentary waiting
+ // state before a late skipWaiting call is not a successful prompt update.
+ await page.waitForTimeout(3_500);
+ expect(navigations).toBe(beforeUpdate);
+ expect(
+ await page.evaluate(async () => {
+ const registration = await navigator.serviceWorker.ready;
+ return registration.waiting?.state;
+ }),
+ ).toBe('installed');
+ await expectUserData(page);
+
+ await Promise.all([page.waitForEvent('framenavigated', frame => frame === page.mainFrame()), reload.click()]);
+ await expect(page.locator('.landing')).toBeVisible();
+ expect(navigations).toBe(beforeUpdate + 1);
+ await expect
+ .poll(() =>
+ page.evaluate(async () => {
+ const registration = await navigator.serviceWorker.ready;
+ return registration.waiting === null && registration.active?.state === 'activated';
+ }),
+ )
+ .toBe(true);
+ await expectUserData(page);
+ } finally {
+ await context.close();
+ await fixture.close();
+ }
+ });
+});
diff --git a/apps/host/tests/functional/polkavm.spec.ts b/apps/host/tests/functional/polkavm.spec.ts
index 73c9c952..75633e69 100644
--- a/apps/host/tests/functional/polkavm.spec.ts
+++ b/apps/host/tests/functional/polkavm.spec.ts
@@ -6,7 +6,7 @@ import { CarReader } from '@ipld/car';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
// Node-loaded specs use the side-effect-free contract, not the browser config barrel.
-import { SANDBOX_SCHEMA_VERSION } from '../../../../packages/config/src/host-sandbox-contract.js';
+import { SANDBOX_SCHEMA_VERSION } from '../../../../packages/config/src/host-sandbox-version.js';
import {
archiveCar,
installRepeatedTruapiPortResponder,
diff --git a/apps/host/tests/smoke/polkavm-products.spec.ts b/apps/host/tests/smoke/polkavm-products.spec.ts
index b13395b8..14e79c97 100644
--- a/apps/host/tests/smoke/polkavm-products.spec.ts
+++ b/apps/host/tests/smoke/polkavm-products.spec.ts
@@ -216,6 +216,7 @@ async function smokeProduct(page: Page, product: ProductSmoke): Promise void;
hasFileInput: boolean;
+ /** Lines describing network access the host granted this execution; read on every open. */
+ grants: () => readonly string[];
retry: () => void;
launcher: () => void;
error?: string;
@@ -59,6 +61,25 @@ export function installPolkaVmMenu(
list.append(item);
}
help.append(summary, list);
+ const network = document.createElement('details');
+ network.className = 'dotli-polkavm-network';
+ const networkSummary = document.createElement('summary');
+ networkSummary.textContent = 'Network access';
+ const networkList = document.createElement('ul');
+ network.append(networkSummary, networkList);
+ const renderGrants = (): void => {
+ const grants = options.grants();
+ networkList.replaceChildren(
+ ...(grants.length === 0 ? ["This app has no network access beyond the host's own services."] : grants).map(
+ grant => {
+ const item = document.createElement('li');
+ item.textContent = grant;
+ return item;
+ },
+ ),
+ );
+ };
+ renderGrants();
const changeFile = options.hasFileInput ? button('Change Game / Choose file') : null;
if (changeFile !== null) {
changeFile.id = 'dotli-polkavm-file-open';
@@ -67,7 +88,7 @@ export function installPolkaVmMenu(
retry.hidden = options.error === undefined;
const launcher = button('Return to launcher');
launcher.hidden = !options.hasFileInput;
- dialog.append(heading, message, resume, help);
+ dialog.append(heading, message, resume, help, network);
if (changeFile !== null) {
dialog.append(changeFile);
}
@@ -80,6 +101,7 @@ export function installPolkaVmMenu(
return;
}
previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ renderGrants();
options.pause(true);
dialog.showModal();
toggle.setAttribute('aria-expanded', 'true');
diff --git a/apps/sandbox/src/polkavm-peer-permission.test.ts b/apps/sandbox/src/polkavm-peer-permission.test.ts
new file mode 100644
index 00000000..16f08b86
--- /dev/null
+++ b/apps/sandbox/src/polkavm-peer-permission.test.ts
@@ -0,0 +1,337 @@
+// @vitest-environment node
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { describe, expect, it, vi } from 'vitest';
+import {
+ decodeWireMessage,
+ encodeWireMessage,
+ MESSAGE_TYPE_REQUEST,
+ MESSAGE_TYPE_RESPONSE,
+ TRUAPI_CODEC_VERSION,
+ VersionedHostHandshakeRequest,
+ VersionedHostJamPeerTransportDialError,
+ VersionedHostJamPeerTransportDialRequest,
+ VersionedHostJamPeerTransportDialResponse,
+ VersionedHostJamPeerTransportEventsRequest,
+ VersionedRemotePermissionError,
+ VersionedRemotePermissionRequest,
+ VersionedRemotePermissionResponse,
+} from '@parity/truapi';
+import {
+ createJamPeerTransportSession,
+ type JamPeerTransportSession,
+ type WebTransportLike,
+} from '@parity/truapi/jam-peer-transport';
+import * as S from '@parity/truapi/scale';
+import {
+ ACCOUNT_GET_ACCOUNT,
+ JAM_PEER_TRANSPORT_DIAL,
+ JAM_PEER_TRANSPORT_EVENTS,
+ PERMISSIONS_REQUEST_REMOTE_PERMISSION,
+ SYSTEM_HANDSHAKE,
+} from '@parity/truapi/wire-table';
+import { JamPeersPermissionRequester } from './polkavm-peer-permission.js';
+import { dispatchHostFrame } from './polkavm-runtime.js';
+
+const GENESIS = `0x3539${'35'.repeat(30)}` as const;
+const OTHER_GENESIS = `0x${'ab'.repeat(32)}` as const;
+/** A compressed P-256 point: the session derives WebTransport certificate hashes from it. */
+const P256 = '0x028874174c8f469438a1b1bab2fde75f9c4999461382ec6d47e9b3b4511294c607';
+const dialResult = S.Result(
+ VersionedHostJamPeerTransportDialResponse,
+ S.CallError(VersionedHostJamPeerTransportDialError),
+);
+const permissionResult = S.Result(VersionedRemotePermissionResponse, S.CallError(VersionedRemotePermissionError));
+
+let requestCounter = 0;
+function frame(
+ ids: { trait: number; method: number },
+ value: Uint8Array,
+ messageType = MESSAGE_TYPE_REQUEST,
+ requestId = `guest-${String(requestCounter++)}`,
+): Uint8Array {
+ const encoded = encodeWireMessage({
+ requestId,
+ payload: { traitId: ids.trait, methodId: ids.method, messageType, value },
+ });
+ if (encoded.isErr()) {
+ throw encoded.error;
+ }
+ return new Uint8Array(encoded.value);
+}
+
+const handshake = (): Uint8Array =>
+ frame(
+ SYSTEM_HANDSHAKE,
+ VersionedHostHandshakeRequest.enc({
+ tag: 'V1',
+ value: { codecVersion: TRUAPI_CODEC_VERSION },
+ }),
+ );
+
+const dial = (genesis: `0x${string}`): Uint8Array =>
+ frame(
+ JAM_PEER_TRANSPORT_DIAL,
+ VersionedHostJamPeerTransportDialRequest.enc({
+ tag: 'V1',
+ value: {
+ genesis,
+ ip: `0x${'00'.repeat(10)}ffff7f000001`,
+ port: 43000,
+ ed25519: `0x${'11'.repeat(32)}`,
+ p256: P256,
+ },
+ }),
+ );
+
+function fakeTransport(): WebTransportLike {
+ return {
+ ready: Promise.resolve(),
+ closed: new Promise(() => undefined),
+ incomingBidirectionalStreams: new ReadableStream(),
+ createBidirectionalStream: () => Promise.reject(new Error('unused')),
+ close: () => undefined,
+ };
+}
+
+/**
+ * One execution: the sandbox's requester and session on a fake authenticated
+ * host port. The fake host answers `permissions.request_remote_permission`
+ * like the core does after its prompt, and records what the guest sent.
+ */
+function execution(answer: (genesis: string) => boolean): {
+ send: (request: Uint8Array) => void;
+ responses: () => Promise;
+ prompts: string[];
+ hostFrames: Uint8Array[];
+ connects: string[];
+ requester: JamPeersPermissionRequester;
+ session: JamPeerTransportSession;
+} {
+ const prompts: string[] = [];
+ const hostFrames: Uint8Array[] = [];
+ const connects: string[] = [];
+ const guestResponses: Uint8Array[] = [];
+ const errors: Error[] = [];
+ let requester: JamPeersPermissionRequester | null = null;
+ const port = {
+ postMessage(message: unknown): void {
+ if (!(message instanceof Uint8Array)) {
+ throw new Error('Expected a binary host frame');
+ }
+ const bytes = message;
+ const decoded = decodeWireMessage(bytes);
+ if (decoded.isErr()) {
+ throw decoded.error;
+ }
+ const { requestId, payload } = decoded.value;
+ if (
+ payload.traitId !== PERMISSIONS_REQUEST_REMOTE_PERMISSION.trait ||
+ payload.methodId !== PERMISSIONS_REQUEST_REMOTE_PERMISSION.method
+ ) {
+ hostFrames.push(bytes);
+ return;
+ }
+ const request = VersionedRemotePermissionRequest.dec(payload.value);
+ const { permission } = request.value;
+ if (permission.tag !== 'JamPeers') {
+ throw new Error(`unexpected permission ${permission.tag}`);
+ }
+ prompts.push(permission.value.genesis);
+ const reply = frame(
+ PERMISSIONS_REQUEST_REMOTE_PERMISSION,
+ permissionResult.enc({
+ success: true,
+ value: {
+ tag: 'V1',
+ value: { granted: answer(permission.value.genesis) },
+ },
+ }),
+ MESSAGE_TYPE_RESPONSE,
+ requestId,
+ );
+ queueMicrotask(() => {
+ // The sandbox consumes its own reply; the guest never sees it.
+ expect(requester?.claim(reply)).toBe(true);
+ });
+ },
+ };
+ requester = new JamPeersPermissionRequester(port, { webTransportAvailable: true });
+ const session = createJamPeerTransportSession({
+ authorize: requester.authorize,
+ connect: url => {
+ connects.push(url);
+ return fakeTransport();
+ },
+ now: () => 1_790_380_800,
+ });
+ const pending: Promise[] = [];
+ return {
+ send: request => {
+ // Requests of the JamPeerTransport trait resolve on the session's reply;
+ // everything else is posted to the host synchronously.
+ const { promise, resolve } = Promise.withResolvers();
+ pending.push(promise);
+ const routed = dispatchHostFrame(
+ request,
+ port,
+ session,
+ response => {
+ guestResponses.push(response);
+ resolve(undefined);
+ },
+ error => {
+ errors.push(error);
+ resolve(undefined);
+ },
+ );
+ expect(routed).toBe(true);
+ if (wireTrait(request) !== JAM_PEER_TRANSPORT_DIAL.trait) {
+ resolve(undefined);
+ }
+ },
+ responses: async () => {
+ await Promise.all(pending);
+ expect(errors).toEqual([]);
+ return guestResponses;
+ },
+ prompts,
+ hostFrames,
+ connects,
+ requester,
+ session,
+ };
+}
+
+function wireTrait(bytes: Uint8Array): number | undefined {
+ const decoded = decodeWireMessage(bytes);
+ return decoded.isOk() ? decoded.value.payload.traitId : undefined;
+}
+
+function dialOutcome(response: Uint8Array): string {
+ const decoded = decodeWireMessage(response);
+ if (decoded.isErr()) {
+ throw decoded.error;
+ }
+ const result = dialResult.dec(decoded.value.payload.value);
+ if (result.success) {
+ return `conn ${String(result.value.value.conn)}`;
+ }
+ return result.value.tag === 'Domain' ? result.value.value.value : result.value.tag;
+}
+
+describe('PolkaVM JAM peer access as a runtime permission', () => {
+ it('answers a denied dial NotGranted without opening a WebTransport', async () => {
+ const app = execution(() => false);
+ app.send(handshake());
+ app.send(dial(GENESIS));
+
+ const responses = await app.responses();
+ expect(responses.map(dialOutcome)).toEqual(['NotGranted']);
+ expect(app.prompts).toEqual([GENESIS]);
+ expect(app.connects).toEqual([]);
+ expect(app.requester.granted()).toEqual([]);
+ app.requester.close();
+ app.session.close();
+ });
+
+ it('dials the peer once the host grants the network, and lists the grant', async () => {
+ const app = execution(() => true);
+ app.send(handshake());
+ app.send(dial(GENESIS));
+
+ const responses = await app.responses();
+ expect(responses.map(dialOutcome)).toEqual(['conn 1']);
+ expect(app.connects).toHaveLength(1);
+ expect(app.requester.granted()).toEqual([GENESIS]);
+ // Only the guest handshake reached the host as a guest frame.
+ expect(app.hostFrames.map(wireTrait)).toEqual([SYSTEM_HANDSHAKE.trait]);
+ app.session.close();
+ });
+
+ it('prompts once for six dials of one network and separately for another', async () => {
+ const app = execution(genesis => genesis === GENESIS);
+ app.send(handshake());
+ for (let index = 0; index < 6; index++) {
+ app.send(dial(GENESIS));
+ }
+ app.send(dial(OTHER_GENESIS));
+
+ const outcomes = (await app.responses()).map(dialOutcome);
+ expect(outcomes.filter(outcome => outcome.startsWith('conn'))).toHaveLength(6);
+ expect(outcomes.filter(outcome => outcome === 'NotGranted')).toHaveLength(1);
+ expect(app.prompts).toEqual([GENESIS, OTHER_GENESIS]);
+ expect(app.connects).toHaveLength(6);
+ expect(app.requester.granted()).toEqual([GENESIS]);
+ app.session.close();
+ });
+
+ it('never asks the host on behalf of an app that never dials', async () => {
+ const app = execution(() => true);
+ app.send(handshake());
+ app.send(frame(ACCOUNT_GET_ACCOUNT, new Uint8Array()));
+ app.send(frame(JAM_PEER_TRANSPORT_EVENTS, VersionedHostJamPeerTransportEventsRequest.enc({ tag: 'V1' })));
+
+ await app.responses();
+ expect(app.prompts).toEqual([]);
+ expect(app.hostFrames.map(wireTrait)).toEqual([SYSTEM_HANDSHAKE.trait, ACCOUNT_GET_ACCOUNT.trait]);
+ app.session.close();
+ });
+
+ it('leaves guest replies to the guest and refuses outstanding requests on stop', async () => {
+ const port = { postMessage: vi.fn() };
+ const requester = new JamPeersPermissionRequester(port, { webTransportAvailable: true });
+ const guestReply = frame(
+ PERMISSIONS_REQUEST_REMOTE_PERMISSION,
+ permissionResult.enc({
+ success: true,
+ value: { tag: 'V1', value: { granted: true } },
+ }),
+ MESSAGE_TYPE_RESPONSE,
+ );
+ expect(requester.claim(guestReply)).toBe(false);
+
+ const answer = requester.authorize(GENESIS);
+ expect(port.postMessage).toHaveBeenCalledTimes(1);
+ expect(requester.claim(guestReply)).toBe(false);
+ requester.close();
+ await expect(answer).resolves.toBe(false);
+ await expect(requester.authorize(GENESIS)).resolves.toBe(false);
+ expect(port.postMessage).toHaveBeenCalledTimes(1);
+ });
+
+ it('refuses locally and reports once when WebTransport is unavailable', async () => {
+ const port = { postMessage: vi.fn() };
+ const onWebTransportUnavailable = vi.fn();
+ const requester = new JamPeersPermissionRequester(port, {
+ webTransportAvailable: false,
+ onWebTransportUnavailable,
+ });
+
+ await expect(Promise.all([requester.authorize(GENESIS), requester.authorize(OTHER_GENESIS)])).resolves.toEqual([
+ false,
+ false,
+ ]);
+ expect(port.postMessage).not.toHaveBeenCalled();
+ expect(onWebTransportUnavailable).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps the 1 MiB host bound for non-peer frames', () => {
+ const port = { postMessage: vi.fn() };
+ const session = createJamPeerTransportSession({
+ authorize: () => Promise.resolve(true),
+ });
+ expect(
+ dispatchHostFrame(
+ new Uint8Array(1024 * 1024 + 1),
+ port,
+ session,
+ () => undefined,
+ () => undefined,
+ ),
+ ).toBe(false);
+ expect(port.postMessage).not.toHaveBeenCalled();
+ session.close();
+ });
+});
diff --git a/apps/sandbox/src/polkavm-peer-permission.ts b/apps/sandbox/src/polkavm-peer-permission.ts
new file mode 100644
index 00000000..2727fb92
--- /dev/null
+++ b/apps/sandbox/src/polkavm-peer-permission.ts
@@ -0,0 +1,219 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// JAM peer access is a runtime permission, never a manifest capability. When
+// the guest dials a JAM network, the sandbox asks the host over the same
+// authenticated TrUAPI port the guest uses, with a request id of its own, and
+// consumes the reply itself. The host core checks the product's stored
+// decision, prompts when it is undetermined and persists the answer per
+// product and genesis.
+
+import {
+ decodeWireMessage,
+ encodeWireMessage,
+ MESSAGE_TYPE_REQUEST,
+ MESSAGE_TYPE_RESPONSE,
+ VersionedRemotePermissionError,
+ VersionedRemotePermissionRequest,
+ VersionedRemotePermissionResponse,
+} from '@parity/truapi';
+import * as S from '@parity/truapi/scale';
+import { PERMISSIONS_REQUEST_REMOTE_PERMISSION } from '@parity/truapi/wire-table';
+
+const remotePermissionResult = S.Result(VersionedRemotePermissionResponse, S.CallError(VersionedRemotePermissionError));
+const REQUEST_ID_PREFIX = 'dotli-jam-peers:';
+/** Prefix plus 16 random bytes in hex: unguessable by the guest sharing the port. */
+const REQUEST_ID_BYTES = REQUEST_ID_PREFIX.length + 32;
+const textDecoder = new TextDecoder();
+
+export interface HostPermissionPort {
+ postMessage(message: unknown, transfer: Transferable[]): void;
+}
+
+export interface JamPeersPermissionRequesterOptions {
+ /** Override only for deterministic tests; production probes the browser global. */
+ webTransportAvailable?: boolean;
+ onWebTransportUnavailable?: () => void;
+}
+
+/**
+ * Byte range of the SCALE `str` request id heading a wire frame, or `null`
+ * when the frame is truncated. Reads the header only, so routing a large
+ * frame never copies its payload.
+ */
+function requestIdRange(frame: Uint8Array): [number, number] | null {
+ const first = frame[0];
+ const second = frame[1];
+ const third = frame[2];
+ const fourth = frame[3];
+ if (first === undefined || second === undefined || third === undefined || fourth === undefined) {
+ return null;
+ }
+ let length: number;
+ let start: number;
+ switch (first & 3) {
+ case 0:
+ length = first >>> 2;
+ start = 1;
+ break;
+ case 1:
+ length = (first | (second << 8)) >>> 2;
+ start = 2;
+ break;
+ case 2:
+ length = (first | (second << 8) | (third << 16) | (fourth << 24)) >>> 2;
+ start = 4;
+ break;
+ default:
+ return null;
+ }
+ const end = start + length;
+ return frame.length < end + 3 ? null : [start, end];
+}
+
+/** Trait id of a wire frame without copying its payload; `undefined` when truncated. */
+export function wireFrameTraitId(frame: Uint8Array): number | undefined {
+ const range = requestIdRange(frame);
+ return range === null ? undefined : frame[range[1]];
+}
+
+/** Menu lines for the JAM networks this execution may reach; empty when none. */
+export function jamPeersGrantText(granted: readonly string[]): string[] {
+ if (granted.length === 0) {
+ return [];
+ }
+ return [
+ ...granted.map(
+ genesis =>
+ `JAM network ${genesis}: read-only peer access (WebTransport) to its validators, granted for this app and closed when it stops.`,
+ ),
+ 'Limits: 8 connections, 16 streams per connection, 1 MiB messages. Received bytes are unverified until the app checks them.',
+ 'This access carries no account, signing, storage or web access.',
+ ];
+}
+
+/**
+ * Asks the host for `RemotePermission::JamPeers { genesis }` on behalf of the
+ * execution-local JamPeerTransport session. Replies to its own requests never
+ * reach the guest.
+ */
+export class JamPeersPermissionRequester {
+ private readonly port: HostPermissionPort;
+ private readonly webTransportAvailable: boolean;
+ private readonly onWebTransportUnavailable: () => void;
+ private readonly pending = new Map void>();
+ private readonly grantedGenesis = new Set();
+ private closed = false;
+ private unavailableReported = false;
+
+ constructor(port: HostPermissionPort, options: JamPeersPermissionRequesterOptions = {}) {
+ this.port = port;
+ this.webTransportAvailable =
+ options.webTransportAvailable ?? typeof Reflect.get(globalThis, 'WebTransport') === 'function';
+ this.onWebTransportUnavailable = options.onWebTransportUnavailable ?? (() => undefined);
+ }
+
+ /** JamPeerTransport `authorize` callback: resolves whether the host granted `genesis`. */
+ readonly authorize = (genesis: string): Promise => {
+ if (this.closed) {
+ return Promise.resolve(false);
+ }
+ if (!this.webTransportAvailable) {
+ if (!this.unavailableReported) {
+ this.unavailableReported = true;
+ this.onWebTransportUnavailable();
+ }
+ return Promise.resolve(false);
+ }
+ const nonce = crypto.getRandomValues(new Uint8Array(16));
+ const requestId = REQUEST_ID_PREFIX + Array.from(nonce, byte => byte.toString(16).padStart(2, '0')).join('');
+ const encoded = encodeWireMessage({
+ requestId,
+ payload: {
+ traitId: PERMISSIONS_REQUEST_REMOTE_PERMISSION.trait,
+ methodId: PERMISSIONS_REQUEST_REMOTE_PERMISSION.method,
+ messageType: MESSAGE_TYPE_REQUEST,
+ value: VersionedRemotePermissionRequest.enc({
+ tag: 'V1',
+ value: {
+ permission: {
+ tag: 'JamPeers',
+ value: { genesis: S.toHexString(genesis) },
+ },
+ },
+ }),
+ },
+ });
+ if (encoded.isErr()) {
+ return Promise.reject(encoded.error);
+ }
+ const { promise, resolve } = Promise.withResolvers();
+ this.pending.set(requestId, granted => {
+ if (granted) {
+ this.grantedGenesis.add(genesis);
+ }
+ resolve(granted);
+ });
+ this.port.postMessage(encoded.value, []);
+ return promise;
+ };
+
+ /**
+ * Consume `frame` when it answers one of this requester's requests. Every
+ * other frame belongs to the guest and is left untouched. Throws, after
+ * refusing the request, when the host's reply to it does not decode.
+ */
+ claim(frame: Uint8Array): boolean {
+ if (this.pending.size === 0) {
+ return false;
+ }
+ const range = requestIdRange(frame);
+ if (range === null || range[1] - range[0] !== REQUEST_ID_BYTES) {
+ return false;
+ }
+ const requestId = textDecoder.decode(frame.subarray(range[0], range[1]));
+ const settle = this.pending.get(requestId);
+ if (settle === undefined) {
+ return false;
+ }
+ this.pending.delete(requestId);
+ let granted = false;
+ try {
+ const decoded = decodeWireMessage(frame);
+ if (decoded.isErr()) {
+ throw decoded.error;
+ }
+ const { payload } = decoded.value;
+ // A protocol error or any other reply to this id grants nothing.
+ if (
+ payload.traitId === PERMISSIONS_REQUEST_REMOTE_PERMISSION.trait &&
+ payload.methodId === PERMISSIONS_REQUEST_REMOTE_PERMISSION.method &&
+ payload.messageType === MESSAGE_TYPE_RESPONSE
+ ) {
+ const result = remotePermissionResult.dec(payload.value);
+ granted = result.success && result.value.value.granted;
+ }
+ } catch (error) {
+ settle(false);
+ throw new Error('Host returned a malformed JAM peers permission reply', {
+ cause: error,
+ });
+ }
+ settle(granted);
+ return true;
+ }
+
+ /** Genesis hashes the host granted to this execution, in grant order. */
+ granted(): string[] {
+ return [...this.grantedGenesis];
+ }
+
+ /** Refuse outstanding and future requests when the execution stops. */
+ close(): void {
+ this.closed = true;
+ for (const settle of this.pending.values()) {
+ settle(false);
+ }
+ this.pending.clear();
+ }
+}
diff --git a/apps/sandbox/src/polkavm-runtime.ts b/apps/sandbox/src/polkavm-runtime.ts
index c341be58..ed1b99a4 100644
--- a/apps/sandbox/src/polkavm-runtime.ts
+++ b/apps/sandbox/src/polkavm-runtime.ts
@@ -3,6 +3,13 @@
import type { ArchiveFiles } from '@dotli/content';
import type { PolkaVmDebugMessage, PolkaVmDebugSnapshot } from '@dotli/truapi-debug';
+import {
+ createJamPeerTransportSession,
+ JAM_PEER_TRANSPORT_MAX_FRAME_BYTES,
+ type JamPeerTransportSession,
+} from '@parity/truapi/jam-peer-transport';
+import { JAM_PEER_TRANSPORT_DIAL, SYSTEM_HANDSHAKE } from '@parity/truapi/wire-table';
+import { jamPeersGrantText, JamPeersPermissionRequester, wireFrameTraitId } from './polkavm-peer-permission.js';
import { POLKAVM_RUNTIME_SOURCE, polkaVmRuntimeAssetUrl } from './polkavm-runtime-assets.js';
import { Tri2dRenderer } from './tri2d-renderer.js';
import { installPolkaVmMenu, type PolkaVmMenu } from './polkavm-menu.js';
@@ -18,7 +25,9 @@ const MAX_AUDIO_BYTES = 48_000 * 2 * 2;
const MAX_SAVE_BYTES = 1024 * 1024;
const MAX_TRANSLATED_WASM_BYTES = 16 * 1024 * 1024;
const MAX_HOST_FRAME_BYTES = 1024 * 1024;
-const MAX_PENDING_HOST_FRAMES = 32;
+// A JamPeerTransport guest keeps several requests in flight per tick (one recv
+// per stream plus events); the byte bound below is what limits memory.
+const MAX_PENDING_HOST_FRAMES = 256;
const MAX_PENDING_HOST_FRAME_BYTES = 4 * 1024 * 1024;
const MAX_UI_OUTPUT_COMMANDS = 64;
const MAX_UI_COPY_TEXT_BYTES = 64 * 1024;
@@ -980,6 +989,47 @@ export interface HostFrameResponseTarget {
postMessage(message: unknown, transfer: Transferable[]): void;
}
+/**
+ * Route one guest host frame. Every frame goes to the authenticated host port
+ * except `JamPeerTransport` (trait 23) requests, which the execution-local peer
+ * session answers once the host has granted the dialed JAM network. The
+ * handshake is the one frame both must see: the peer session negotiates on a
+ * copy and its reply is dropped, so the guest only ever observes the host's
+ * answer.
+ *
+ * Returns `false` when the frame exceeds the bound for its route. Only a
+ * JamPeerTransport frame may exceed `MAX_HOST_FRAME_BYTES`, and then only up
+ * to a `send` of one maximal message.
+ */
+export function dispatchHostFrame(
+ request: Uint8Array,
+ hostFramePort: HostFrameResponseTarget,
+ peerSession: JamPeerTransportSession,
+ onPeerResponse: (response: Uint8Array) => void,
+ onPeerError: (error: Error) => void,
+): boolean {
+ const trait = wireFrameTraitId(request);
+ if (trait === JAM_PEER_TRANSPORT_DIAL.trait) {
+ if (request.byteLength > JAM_PEER_TRANSPORT_MAX_FRAME_BYTES) {
+ return false;
+ }
+ void peerSession.handleFrame(request).then(onPeerResponse, (error: unknown) => {
+ onPeerError(error instanceof Error ? error : new Error('JamPeerTransport frame dispatch failed'));
+ });
+ return true;
+ }
+ if (request.byteLength > MAX_HOST_FRAME_BYTES) {
+ return false;
+ }
+ if (trait === SYSTEM_HANDSHAKE.trait) {
+ // The session refuses a malformed handshake itself; the host's verdict on
+ // the same bytes is the one the guest receives.
+ void peerSession.handleFrame(request.slice()).catch(() => undefined);
+ }
+ hostFramePort.postMessage(request, [request.buffer]);
+ return true;
+}
+
export interface HostFrameResponseQueueOptions {
maxResponses?: number;
maxBytes?: number;
@@ -2603,6 +2653,7 @@ export async function runPolkaVmApplication(
const menu = installPolkaVmMenu(surface, canvas, descriptor.controls, {
pause: () => undefined,
hasFileInput: descriptor.fileInputHandlers.length > 0,
+ grants: () => [],
error: error instanceof Error ? error.message : 'Application startup failed.',
retry: () => {
void launch(nextFiles);
@@ -2706,6 +2757,19 @@ async function startPolkaVmApplication(
reject: rejectStarted,
} = Promise.withResolvers();
const hostFramePort = await waitForTruapiPort(window, window.parent, parentOrigin);
+ // Every application may dial a JAM network, but only after the host grants
+ // `RemotePermission::JamPeers { genesis }` for this product. The request
+ // travels over the authenticated host port with the sandbox's own request
+ // id and its reply never reaches the guest. The grant is execution-local
+ // and carries no account, signing, storage or arbitrary-URL authority.
+ const jamPeersPermission = new JamPeersPermissionRequester(hostFramePort, {
+ onWebTransportUnavailable: () => {
+ window.parent.postMessage({ type: 'dotli:jam-peer-transport-unavailable' }, parentOrigin);
+ },
+ });
+ const peerSession = createJamPeerTransportSession({
+ authorize: jamPeersPermission.authorize,
+ });
const worker = new Worker(polkaVmRuntimeAssetUrl('polkavm-worker.js'));
const closeHostFramePort = (): void => {
hostFramePort.onmessage = null;
@@ -2720,6 +2784,8 @@ async function startPolkaVmApplication(
let failRuntime = (error: Error): void => {
status.textContent = error.message;
rejectStarted(error);
+ jamPeersPermission.close();
+ peerSession.close();
worker.postMessage({ type: 'stop' });
worker.terminate();
closeHostFramePort();
@@ -2737,6 +2803,14 @@ async function startPolkaVmApplication(
failHostFrame(new Error('Host returned an invalid host frame'));
return;
}
+ try {
+ if (jamPeersPermission.claim(event.data)) {
+ return;
+ }
+ } catch (error) {
+ failHostFrame(error instanceof Error ? error : new Error(String(error)));
+ return;
+ }
canvas.dataset['polkavmHostFrameResponses'] = String(Number(canvas.dataset['polkavmHostFrameResponses']) + 1);
hostFrameQueue.enqueue(event.data);
};
@@ -3142,6 +3216,8 @@ async function startPolkaVmApplication(
worker.terminate();
webGpu?.dispose();
void audioContext?.close();
+ jamPeersPermission.close();
+ peerSession.close();
closeHostFramePort();
hostFrameQueue.close();
};
@@ -3222,6 +3298,7 @@ async function startPolkaVmApplication(
syncPause();
},
hasFileInput: descriptor.fileInputHandlers.length > 0,
+ grants: () => jamPeersGrantText(jamPeersPermission.granted()),
retry: () => {
resolveStarted(undefined);
stop();
@@ -3447,13 +3524,35 @@ async function startPolkaVmApplication(
}
case 'host-frame-request': {
const bytes = message['bytes'];
- if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0 || bytes.byteLength > MAX_HOST_FRAME_BYTES) {
+ if (
+ !(bytes instanceof Uint8Array) ||
+ bytes.byteLength === 0 ||
+ bytes.byteLength > JAM_PEER_TRANSPORT_MAX_FRAME_BYTES
+ ) {
failRuntime(new Error('PolkaVM guest emitted an invalid host frame'));
return;
}
const request = ownedBytes(bytes);
canvas.dataset['polkavmHostFrameRequests'] = String(Number(canvas.dataset['polkavmHostFrameRequests']) + 1);
- hostFramePort.postMessage(request, [request.buffer]);
+ const routed = dispatchHostFrame(
+ request,
+ hostFramePort,
+ peerSession,
+ response => {
+ if (stopped || response.byteLength === 0) {
+ return;
+ }
+ canvas.dataset['polkavmHostFrameResponses'] = String(
+ Number(canvas.dataset['polkavmHostFrameResponses']) + 1,
+ );
+ hostFrameQueue.enqueue(response);
+ },
+ failRuntime,
+ );
+ if (!routed) {
+ failRuntime(new Error('PolkaVM guest emitted an invalid host frame'));
+ return;
+ }
break;
}
case 'frame': {
diff --git a/package-lock.json b/package-lock.json
index 030bc58e..105906b8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -109,6 +109,7 @@
"@dotli/storage": "*",
"@dotli/truapi-debug": "*",
"@dotli/ui": "*",
+ "@parity/truapi": "file:../../vendor/truapi",
"@sentry/browser": "^11.1.0",
"@sentry/vite-plugin": "^5.4.0"
},
diff --git a/packages/config/src/host-sandbox-contract.ts b/packages/config/src/host-sandbox-contract.ts
index 7e0b5c78..4f3e9a81 100644
--- a/packages/config/src/host-sandbox-contract.ts
+++ b/packages/config/src/host-sandbox-contract.ts
@@ -34,8 +34,7 @@
// don't feed malformed params to fresh sandbox deploys.
import { NetworkName, isValidNetwork, type Network } from './network.js';
-
-export const SANDBOX_SCHEMA_VERSION = 5;
+import { SANDBOX_SCHEMA_VERSION } from './host-sandbox-version.js';
// Cheap CID charset gate (base32 cidv1 / base58btc cidv0 are alphanumeric).
// The sandbox does the authoritative CID.parse, then hash-verifies fetched
diff --git a/packages/config/src/host-sandbox-version.ts b/packages/config/src/host-sandbox-version.ts
new file mode 100644
index 00000000..7c496931
--- /dev/null
+++ b/packages/config/src/host-sandbox-version.ts
@@ -0,0 +1,5 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// Shared by browser validation and Node-loaded build configuration.
+export const SANDBOX_SCHEMA_VERSION = 5;
diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts
index ecd2f839..9e5fe210 100644
--- a/packages/config/src/index.ts
+++ b/packages/config/src/index.ts
@@ -20,12 +20,12 @@ export {
} from './config.js';
export {
SANDBOX_CONTRACT_PARAMS,
- SANDBOX_SCHEMA_VERSION,
validateSandboxParams,
type SandboxContractParam,
type SandboxParams,
type SandboxParamsResult,
} from './host-sandbox-contract.js';
+export { SANDBOX_SCHEMA_VERSION } from './host-sandbox-version.js';
export {
BACKEND_KEY,
BACKEND_LABELS,
diff --git a/packages/config/tests/host-sandbox-contract.test.ts b/packages/config/tests/host-sandbox-contract.test.ts
index e7d9a586..4c820921 100644
--- a/packages/config/tests/host-sandbox-contract.test.ts
+++ b/packages/config/tests/host-sandbox-contract.test.ts
@@ -2,11 +2,8 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { describe, it, expect } from 'vitest';
-import {
- SANDBOX_SCHEMA_VERSION,
- SANDBOX_CONTRACT_PARAMS,
- validateSandboxParams,
-} from '../src/host-sandbox-contract.js';
+import { SANDBOX_CONTRACT_PARAMS, validateSandboxParams } from '../src/host-sandbox-contract.js';
+import { SANDBOX_SCHEMA_VERSION } from '../src/host-sandbox-version.js';
import { NetworkName } from '../src/network.js';
const VALID_CID = 'bafyreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy';
diff --git a/packages/ui/src/bridge.ts b/packages/ui/src/bridge.ts
index 004e876e..540d7e5b 100644
--- a/packages/ui/src/bridge.ts
+++ b/packages/ui/src/bridge.ts
@@ -97,6 +97,7 @@ import { createRendererImageLoader, registerChatConnection } from './chat/servic
import { showNotification } from './notification.js';
import { ERRORS } from './errors.js';
import { disposeAppRoot, disposeAppRoots } from './mount/app-roots.js';
+import { jamPeerTransportUnavailableMessage } from './jam-peer-browser-support.js';
import { mountViolationPanel } from './components/sandbox-checker/mount.js';
import { CameraInputCancelledError, CameraInputPermissionError, scanCameraUr } from './mediated-input-camera.js';
import { MediatedInputHost, validatedMediatedInputRequest } from './mediated-input-host.js';
@@ -854,6 +855,7 @@ window.addEventListener('message', (event: MessageEvent) => {
if (
type !== 'dotli:sandbox-recover' &&
type !== 'dotli:host-update-required' &&
+ type !== 'dotli:jam-peer-transport-unavailable' &&
type !== 'dotli:polkavm-motion-request' &&
type !== 'dotli:polkavm-mediated-input-request' &&
type !== 'dotli:polkavm-mediated-input-cancel'
@@ -871,6 +873,17 @@ window.addEventListener('message', (event: MessageEvent) => {
) {
return;
}
+ if (type === 'dotli:jam-peer-transport-unavailable') {
+ if (typeof Reflect.get(globalThis, 'WebTransport') !== 'function') {
+ showNotification({
+ label: 'Live JAM unavailable',
+ text: jamPeerTransportUnavailableMessage(navigator.userAgent),
+ dismissMs: 0,
+ browserNotification: false,
+ });
+ }
+ return;
+ }
if (type === 'dotli:polkavm-motion-request') {
offerTopLevelMotionPermission(source, event.origin, product.label);
return;
diff --git a/packages/ui/src/host-callbacks/PromptPermission.ts b/packages/ui/src/host-callbacks/PromptPermission.ts
index 205a3a7f..8bd578fe 100644
--- a/packages/ui/src/host-callbacks/PromptPermission.ts
+++ b/packages/ui/src/host-callbacks/PromptPermission.ts
@@ -3,12 +3,12 @@
// user takes to dismiss it. Device grants also schedule an iframe reload so
// the browser sees the refreshed Permissions Policy `allow` attribute.
//
-// "Always allow" and "Deny" are durable, matching the grant the topbar
-// permissions menu shows and resets. "Allow once" is kept by the core for the
-// current execution and consumed by the next operation that needs it, so it
-// is offered only where the core is that gate: the submit permissions and
-// Notifications. A grant gated by the iframe `allow` attribute reloads the
-// product into a new execution, which would drop a one-time grant.
+// "Always allow" and "Deny" are durable. Generic permission grants appear in
+// the topbar permissions menu; JAM peer decisions are keyed by product and
+// genesis in the core. Submit and notification one-time grants are consumed
+// by the next operation. A JAM peer one-time grant authorizes the running
+// peer session. Iframe `allow`-gated permissions offer no one-time grant:
+// granting reloads the product into a new execution.
// Auto-grants answer `AllowOnce` so the core records nothing the user never
// saw. Each instance serves one product, so the product the core passes is
// already known as `label`.
@@ -23,7 +23,7 @@ import {
setPermissionStatus,
type EnforceablePermissionName,
} from '../permissions.js';
-import { showPermissionRequestModal } from '../permission-modal.js';
+import { showJamPeersPermissionModal, showPermissionRequestModal } from '../permission-modal.js';
import { showNotification } from '../notification.js';
import { createBlockingModalScope, throwIfAborted, type BlockingModalScope } from '../blocking-modal-queue.js';
import { createSubmitRateLimiter, type SubmitRateLimiter } from './rate-limit.js';
@@ -33,7 +33,10 @@ import { recordPermissionChange } from '../state/permissions.js';
// Remote tags that don't reach a host enforcement point: WebRtc is gated
// by the iframe `allow` attribute, and `Remote` (HTTP/WS) can't be
// reliably intercepted from inside the sandbox. Auto-grant either.
-function gatedRemotePermissionName(tag: RemotePermission['tag']): EnforceablePermissionName | null {
+// `JamPeers` carries its genesis and has its own prompt.
+function gatedRemotePermissionName(
+ tag: Exclude,
+): EnforceablePermissionName | null {
switch (tag) {
case 'ChainSubmit':
case 'PreimageSubmit':
@@ -60,7 +63,16 @@ export function createPromptPermission(
};
const remotePermission: Permissions['remotePermission'] = async (_product, request) => {
- const name = gatedRemotePermissionName(request.permission.tag);
+ const { permission } = request;
+ if (permission.tag === 'JamPeers') {
+ return modalScope.enqueue(signal =>
+ decideJamPeersPermission(label, permission.value.genesis, {
+ limiter,
+ signal,
+ }),
+ );
+ }
+ const name = gatedRemotePermissionName(permission.tag);
if (name === null) {
return 'AllowOnce';
}
@@ -70,6 +82,33 @@ export function createPromptPermission(
return { devicePermission, remotePermission };
}
+// The core asks only while the product's stored decision for this genesis is
+// undetermined, and itself persists "Always allow" and "Deny" per product and
+// genesis, so the next dial of that network in any execution is answered
+// without a prompt. A dismissal stores nothing.
+async function decideJamPeersPermission(
+ label: string,
+ genesis: string,
+ options: { limiter: { allow: () => boolean }; signal: AbortSignal },
+): Promise {
+ const { limiter, signal } = options;
+ if (!limiter.allow()) {
+ throw new Error(ERRORS.PERMISSION_PROMPT_RATE_LIMITED);
+ }
+ const decision = await showJamPeersPermissionModal(label, genesis, signal);
+ throwIfAborted(signal);
+ switch (decision) {
+ case 'dismissed':
+ throw new Error(ERRORS.PERMISSION_DIALOG_DISMISSED);
+ case 'denied':
+ return 'Deny';
+ case 'granted':
+ return 'AllowAlways';
+ case 'granted-once':
+ return 'AllowOnce';
+ }
+}
+
interface PromptOptions {
kind: 'Device' | 'Remote';
limiter: { allow: () => boolean };
diff --git a/packages/ui/src/jam-peer-browser-support.ts b/packages/ui/src/jam-peer-browser-support.ts
new file mode 100644
index 00000000..ee7678a5
--- /dev/null
+++ b/packages/ui/src/jam-peer-browser-support.ts
@@ -0,0 +1,35 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+/** Browser family and version for user-facing compatibility guidance only. */
+function browserVersion(userAgent: string): string | null {
+ const ios = /\b(?:CPU (?:iPhone )?OS|iPhone OS) (\d+(?:_\d+){0,2})\b/.exec(userAgent);
+ if (ios?.[1] !== undefined) {
+ return `iOS ${ios[1].replaceAll('_', '.')}`;
+ }
+
+ const edge = /\bEdg(?:A|iOS)?\/(\d+(?:\.\d+){0,3})\b/.exec(userAgent);
+ if (edge?.[1] !== undefined) {
+ return `Edge ${edge[1]}`;
+ }
+
+ const chrome = /\b(?:Chrome|CriOS)\/(\d+(?:\.\d+){0,3})\b/.exec(userAgent);
+ if (chrome?.[1] !== undefined) {
+ return `Chrome ${chrome[1]}`;
+ }
+
+ const firefox = /\b(?:Firefox|FxiOS)\/(\d+(?:\.\d+){0,3})\b/.exec(userAgent);
+ if (firefox?.[1] !== undefined) {
+ return `Firefox ${firefox[1]}`;
+ }
+
+ const safari = /\bVersion\/(\d+(?:\.\d+){0,3}).*\bSafari\//.exec(userAgent);
+ return safari?.[1] === undefined ? null : `Safari ${safari[1]}`;
+}
+
+/** Explains a failed WebTransport capability check without using the user agent as the gate. */
+export function jamPeerTransportUnavailableMessage(userAgent: string): string {
+ const browser = browserVersion(userAgent);
+ const subject = browser ?? 'This browser';
+ return `${subject} does not provide the WebTransport support required for live JAM connections. Use Chrome or Edge 100+, Firefox 125+, or Safari/iOS 26.4+. This app will use its verified snapshot instead.`;
+}
diff --git a/packages/ui/src/permission-modal.ts b/packages/ui/src/permission-modal.ts
index d85279ad..306898e3 100644
--- a/packages/ui/src/permission-modal.ts
+++ b/packages/ui/src/permission-modal.ts
@@ -13,8 +13,9 @@ import type { ModalButton } from './state/modals.js';
// variants (Camera, Microphone, Location, Bluetooth, NFC, Clipboard,
// Biometrics, Notifications), Chat identity authority, identity disclosure,
// and the internal submitted gates (ChainSubmit, PreimageSubmit,
-// StatementSubmit). `OpenUrl` is auto-granted at the container level and never
-// reaches this modal.
+// StatementSubmit), plus JAM peer access (`JamPeers`), which names the JAM
+// network it covers. `OpenUrl` is auto-granted at the container level and
+// never reaches this modal.
// Returns an explicit decision so callers can distinguish "Deny" from
// dismissing the dialog without storing a denial. With `allowOnce`, the prompt
// also offers a one-time grant and highlights it over "Always allow".
@@ -108,6 +109,26 @@ const PERMISSION_ICONS: Record = {
'',
};
+const JAM_PEERS_ICON =
+ '';
+
+/** The question asked before an app may reach the validators of one JAM network. */
+export function jamPeersPermissionText(label: string, genesis: string): string {
+ return `Allow ${withActiveTld(label)} to connect to JAM network ${genesis.slice(0, 10)}… (read-only peer access, no accounts or signing)?`;
+}
+
+interface PermissionPrompt {
+ icon: string;
+ description: string;
+ /** Full network identity, displayed separately from the abbreviated question. */
+ detail?: string;
+ /** Granting reloads the application (iframe `allow`-gated permissions). */
+ reloads: boolean;
+}
+
export type PermissionPromptDecision = 'granted' | 'granted-once' | 'denied' | 'dismissed';
export interface PermissionRequestModalOptions {
@@ -123,6 +144,43 @@ export async function showPermissionRequestModal(
permission: EnforceablePermissionName,
signal?: AbortSignal,
options: PermissionRequestModalOptions = {},
+): Promise {
+ return showPermissionPrompt(
+ label,
+ {
+ icon: PERMISSION_ICONS[permission],
+ description: PERMISSION_DESCRIPTIONS[permission],
+ reloads: isDevicePermission(permission),
+ },
+ signal,
+ options,
+ );
+}
+
+/** The shared Solid dialog asks for a grant scoped to this product and genesis. */
+export function showJamPeersPermissionModal(
+ label: string,
+ genesis: string,
+ signal?: AbortSignal,
+): Promise {
+ return showPermissionPrompt(
+ label,
+ {
+ icon: JAM_PEERS_ICON,
+ description: jamPeersPermissionText(label, genesis),
+ detail: genesis,
+ reloads: false,
+ },
+ signal,
+ { allowOnce: true },
+ );
+}
+
+async function showPermissionPrompt(
+ label: string,
+ prompt: PermissionPrompt,
+ signal: AbortSignal | undefined,
+ options: PermissionRequestModalOptions,
): Promise {
const allowOnce = options.allowOnce === true;
const buttons: ModalButton[] = [
@@ -140,13 +198,14 @@ export async function showPermissionRequestModal(
}
const { result } = await presentModal(
{
- icon: PERMISSION_ICONS[permission],
+ icon: prompt.icon,
title: 'Permission Request',
fields: [
{ label: 'Application', value: withActiveTld(label) },
- { label: 'Permission', value: PERMISSION_DESCRIPTIONS[permission] },
+ { label: 'Permission', value: prompt.description },
+ ...(prompt.detail === undefined ? [] : [{ label: 'JAM network genesis', value: prompt.detail, mono: true }]),
],
- ...(isDevicePermission(permission) ? { notice: 'Granting this permission will reload the application.' } : {}),
+ ...(prompt.reloads ? { notice: 'Granting this permission will reload the application.' } : {}),
buttons,
dismissOnBackdrop: true,
dismissResult: 'dismissed',
diff --git a/packages/ui/tests/bridge.test.ts b/packages/ui/tests/bridge.test.ts
index cc81fff9..4c46434d 100644
--- a/packages/ui/tests/bridge.test.ts
+++ b/packages/ui/tests/bridge.test.ts
@@ -808,6 +808,46 @@ describe('bridge render lifecycle', () => {
expect(updateRequired).toHaveBeenCalledTimes(1);
});
+ it('shows browser requirements for an authenticated unsupported JAM transport signal', async () => {
+ vi.stubGlobal('WebTransport', undefined);
+ const { renderAppSubdomain } = await import('../src/bridge.js');
+ const notification = await import('../src/notification.js');
+ const showNotification = vi.spyOn(notification, 'showNotification').mockImplementation(() => () => undefined);
+ const render = renderAppSubdomain('manifest-cid', 'jam-app');
+ await waitForProviderRequests(1);
+ nth(mocks.coreProviderDefers, 0).resolve(makeProvider());
+ await render;
+
+ const targetWindow = nth(mocks.iframeHosts, 0).iframe.contentWindow;
+ if (targetWindow === null) {
+ throw new Error('app frame has no content window');
+ }
+ const appOrigin = new URL(nth(mocks.iframeHosts, 0).iframeUrl).origin;
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ data: { type: 'dotli:jam-peer-transport-unavailable' },
+ origin: 'https://evil.example',
+ source: targetWindow,
+ }),
+ );
+ expect(showNotification).not.toHaveBeenCalled();
+
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ data: { type: 'dotli:jam-peer-transport-unavailable' },
+ origin: appOrigin,
+ source: targetWindow,
+ }),
+ );
+ expect(showNotification).toHaveBeenCalledTimes(1);
+ const notificationCall = showNotification.mock.calls[0]?.[0];
+ expect(notificationCall?.label).toBe('Live JAM unavailable');
+ expect(notificationCall?.text).toContain('Chrome or Edge 100+, Firefox 125+, or Safari/iOS 26.4+');
+ expect(notificationCall?.dismissMs).toBe(0);
+ expect(notificationCall?.browserNotification).toBe(false);
+ showNotification.mockRestore();
+ });
+
it.each(['/x.dot@evil.com/pay', '/foo.dotify/pay'])(
'As a user, the host keeps an adversarial deep path on the app sandbox origin: %s',
async path => {
diff --git a/packages/ui/tests/jam-peer-browser-support.test.ts b/packages/ui/tests/jam-peer-browser-support.test.ts
new file mode 100644
index 00000000..3e0d07bd
--- /dev/null
+++ b/packages/ui/tests/jam-peer-browser-support.test.ts
@@ -0,0 +1,37 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { describe, expect, it } from 'vitest';
+import { jamPeerTransportUnavailableMessage } from '../src/jam-peer-browser-support.js';
+
+const REQUIREMENTS = 'Chrome or Edge 100+, Firefox 125+, or Safari/iOS 26.4+';
+
+describe('JAM peer browser compatibility guidance', () => {
+ it.each([
+ [
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_3 like Mac OS X) AppleWebKit/605.1.15 Version/26.3 Mobile/15E148 Safari/604.1',
+ 'iOS 26.3',
+ ],
+ [
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/26.3 Safari/605.1.15',
+ 'Safari 26.3',
+ ],
+ [
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/99.0.4844.51 Safari/537.36',
+ 'Chrome 99.0.4844.51',
+ ],
+ ['Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0', 'Firefox 124.0'],
+ ])('names the current browser version and supported versions for %s', (userAgent, currentBrowser) => {
+ const message = jamPeerTransportUnavailableMessage(userAgent);
+
+ expect(message).toContain(`${currentBrowser} does not provide the WebTransport support`);
+ expect(message).toContain(REQUIREMENTS);
+ expect(message).toContain('verified snapshot instead');
+ });
+
+ it('uses generic copy when the browser cannot be identified', () => {
+ expect(jamPeerTransportUnavailableMessage('CustomBrowser/1.0')).toBe(
+ `This browser does not provide the WebTransport support required for live JAM connections. Use ${REQUIREMENTS}. This app will use its verified snapshot instead.`,
+ );
+ });
+});
diff --git a/packages/ui/tests/permissions.test.ts b/packages/ui/tests/permissions.test.ts
index 1bd05cce..58302101 100644
--- a/packages/ui/tests/permissions.test.ts
+++ b/packages/ui/tests/permissions.test.ts
@@ -419,6 +419,37 @@ describe('three-way permission prompts', () => {
expect(await getPermissionStatus('myapp', 'ChainSubmit')).toBe('denied');
});
+ it('As a dotli user, I am asked which JAM network an app may reach', async () => {
+ const genesis = `0x3539${'ab'.repeat(30)}` as const;
+ for (const [button, decision] of [
+ ['Always allow', 'AllowAlways'],
+ ['Allow once', 'AllowOnce'],
+ ['Deny', 'Deny'],
+ ] as const) {
+ // Given
+ const response = createPromptPermission('myapp').remotePermission(PRODUCT, {
+ permission: { tag: 'JamPeers', value: { genesis } },
+ });
+ await vi.waitFor(() => {
+ expect(promptButtonTexts()).toContain(button);
+ });
+ const values = [...document.querySelectorAll('.signing-field-value')];
+ expect(values.some(field => field.textContent === genesis)).toBe(true);
+ expect(document.querySelector('.permission-modal-notice')).toBeNull();
+ // When
+ await clickPromptButton(button);
+ await expect(response).resolves.toBe(decision);
+ }
+ const dismissed = createPromptPermission('myapp').remotePermission(PRODUCT, {
+ permission: { tag: 'JamPeers', value: { genesis } },
+ });
+ await vi.waitFor(() => {
+ expect(document.querySelector('.signing-modal-backdrop')).not.toBeNull();
+ });
+ document.querySelector('.signing-modal-backdrop')?.click();
+ await expect(dismissed).rejects.toThrow('User dismissed permission dialog');
+ });
+
it('As a dotli user, I can allow a single notification', async () => {
// Given
const response = createPromptPermission('myapp').devicePermission(PRODUCT, 'Notifications');
diff --git a/vendor/truapi-host.lock.json b/vendor/truapi-host.lock.json
index 84c41934..8c2b0fbc 100644
--- a/vendor/truapi-host.lock.json
+++ b/vendor/truapi-host.lock.json
@@ -1,27 +1,27 @@
{
"schemaVersion": 1,
"upstreamRepository": "https://github.com/paritytech/trinity-user-agents",
- "upstreamBranch": "feat/chat-seity-profile",
- "upstreamRevision": "bd55ec2af4b5db75afaf827404f63d11f6881a02",
- "pullRequest": "https://github.com/paritytech/trinity-user-agents/pull/1001",
+ "upstreamBranch": "feat/jam-peer-transport-on-seity",
+ "upstreamRevision": "f7ac212cf0e19d6860c4d60298d4d1cf442e1c55",
+ "pullRequest": "https://github.com/paritytech/trinity-user-agents/pull/1011",
"packages": {
"vendor/truapi": {
"name": "@parity/truapi",
"version": "0.23.0",
- "sourceArchiveSha256": "59f26eb2d74343958888a9fc09ecd5e4ac424366de8057e3550baa9c97c72c8a",
- "clientSha256": "93cfaec49a733117b20557ea02bfb761891b752bc74428fe38fc4410448f5011"
+ "sourceArchiveSha256": "96ff76fafd3c2e8bec9cf79199be0ad5bd7a1847fe4851cf050b2755555f5abc",
+ "clientSha256": "328f267eb889761ed4f4f11bbec590f30859bdf24450f3a63af4c44d1f85c1f0"
},
"vendor/truapi-host": {
"name": "@parity/truapi-host",
"version": "0.23.0",
- "sourceArchiveSha256": "ca342bee6cfdcc6561facdff181b825c660a8144b9b79f31b7b5e74018c1b5c1",
+ "sourceArchiveSha256": "9241a1d0b57c87dec74578024146df0c50d13ac8196a12e666fc96e4ac260977",
"dependencyOverride": "@parity/truapi=file:../truapi",
- "wasmSha256": "2a2da416abcefa98e02391bee662daa5cd87e346360fb897c930cec076573bf7",
- "testingWasmSha256": "7b1557bfc1aadf2e1258fc581f1be4e56c0ab37002ae93ae5b029dc94d5338d6",
+ "wasmSha256": "0374caaeb839a4030cec7294a55fde9c18543caa129a82dd9b2cc866407c1bb2",
+ "testingWasmSha256": "d8b9d9545c5c0e1907c691436c8b3c60db861944562ae16fe118b87c51b78b21",
"wasmFeatures": [
"wasm-signing-host"
]
}
},
- "wasmUpstreamRevision": "bd55ec2af4b5db75afaf827404f63d11f6881a02"
+ "wasmUpstreamRevision": "f7ac212cf0e19d6860c4d60298d4d1cf442e1c55"
}
diff --git a/vendor/truapi-host/dist/wasm/testing/truapi_server.d.ts b/vendor/truapi-host/dist/wasm/testing/truapi_server.d.ts
index a06e7faa..fbc755a1 100644
--- a/vendor/truapi-host/dist/wasm/testing/truapi_server.d.ts
+++ b/vendor/truapi-host/dist/wasm/testing/truapi_server.d.ts
@@ -467,12 +467,12 @@ export interface InitOutput {
readonly wasmsigninghostruntime_setPermissionAuthorizationStatus: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => number;
readonly wasmsigninghostruntime_setWithheldResources: (a: number, b: number, c: number) => void;
readonly wireSchemaHash: (a: number) => void;
- readonly __wasm_bindgen_func_elem_9895: (a: number, b: number, c: number, d: number) => void;
- readonly __wasm_bindgen_func_elem_9953: (a: number, b: number, c: number, d: number) => void;
- readonly __wasm_bindgen_func_elem_4599: (a: number, b: number, c: number) => void;
- readonly __wasm_bindgen_func_elem_4592: (a: number, b: number, c: number) => void;
- readonly __wasm_bindgen_func_elem_9776: (a: number, b: number) => void;
- readonly __wasm_bindgen_func_elem_4596: (a: number, b: number) => void;
+ readonly __wasm_bindgen_func_elem_9936: (a: number, b: number, c: number, d: number) => void;
+ readonly __wasm_bindgen_func_elem_9994: (a: number, b: number, c: number, d: number) => void;
+ readonly __wasm_bindgen_func_elem_4605: (a: number, b: number, c: number) => void;
+ readonly __wasm_bindgen_func_elem_4598: (a: number, b: number, c: number) => void;
+ readonly __wasm_bindgen_func_elem_9817: (a: number, b: number) => void;
+ readonly __wasm_bindgen_func_elem_4602: (a: number, b: number) => void;
readonly __wbindgen_export: (a: number, b: number) => number;
readonly __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
readonly __wbindgen_export3: (a: number) => void;
diff --git a/vendor/truapi-host/dist/wasm/testing/truapi_server.js b/vendor/truapi-host/dist/wasm/testing/truapi_server.js
index 7fbf465c..fa41f5bb 100644
--- a/vendor/truapi-host/dist/wasm/testing/truapi_server.js
+++ b/vendor/truapi-host/dist/wasm/testing/truapi_server.js
@@ -1260,7 +1260,7 @@ function __wbg_get_imports() {
const a = state0.a;
state0.a = 0;
try {
- return __wasm_bindgen_func_elem_9953(a, state0.b, arg0, arg1);
+ return __wasm_bindgen_func_elem_9994(a, state0.b, arg0, arg1);
} finally {
state0.a = a;
}
@@ -1398,27 +1398,27 @@ function __wbg_get_imports() {
},
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 379, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_9895);
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_9936);
return addHeapObject(ret);
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 98, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_4599);
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_4605);
return addHeapObject(ret);
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 165, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_9776);
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_9817);
return addHeapObject(ret);
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 94, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: false }) -> Externref`.
- const ret = makeClosure(arg0, arg1, __wasm_bindgen_func_elem_4592);
+ const ret = makeClosure(arg0, arg1, __wasm_bindgen_func_elem_4598);
return addHeapObject(ret);
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 96, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
- const ret = makeClosure(arg0, arg1, __wasm_bindgen_func_elem_4596);
+ const ret = makeClosure(arg0, arg1, __wasm_bindgen_func_elem_4602);
return addHeapObject(ret);
},
__wbindgen_cast_0000000000000006: function(arg0) {
@@ -1462,22 +1462,22 @@ function __wbg_get_imports() {
};
}
-function __wasm_bindgen_func_elem_9776(arg0, arg1) {
- wasm.__wasm_bindgen_func_elem_9776(arg0, arg1);
+function __wasm_bindgen_func_elem_9817(arg0, arg1) {
+ wasm.__wasm_bindgen_func_elem_9817(arg0, arg1);
}
-function __wasm_bindgen_func_elem_4596(arg0, arg1) {
- wasm.__wasm_bindgen_func_elem_4596(arg0, arg1);
+function __wasm_bindgen_func_elem_4602(arg0, arg1) {
+ wasm.__wasm_bindgen_func_elem_4602(arg0, arg1);
}
-function __wasm_bindgen_func_elem_4599(arg0, arg1, arg2) {
- wasm.__wasm_bindgen_func_elem_4599(arg0, arg1, addHeapObject(arg2));
+function __wasm_bindgen_func_elem_4605(arg0, arg1, arg2) {
+ wasm.__wasm_bindgen_func_elem_4605(arg0, arg1, addHeapObject(arg2));
}
-function __wasm_bindgen_func_elem_4592(arg0, arg1) {
+function __wasm_bindgen_func_elem_4598(arg0, arg1) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
- wasm.__wasm_bindgen_func_elem_4592(retptr, arg0, arg1);
+ wasm.__wasm_bindgen_func_elem_4598(retptr, arg0, arg1);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
if (r1) {
@@ -1488,10 +1488,10 @@ function __wasm_bindgen_func_elem_4592(arg0, arg1) {
}
}
-function __wasm_bindgen_func_elem_9895(arg0, arg1, arg2) {
+function __wasm_bindgen_func_elem_9936(arg0, arg1, arg2) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
- wasm.__wasm_bindgen_func_elem_9895(retptr, arg0, arg1, addHeapObject(arg2));
+ wasm.__wasm_bindgen_func_elem_9936(retptr, arg0, arg1, addHeapObject(arg2));
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
if (r1) {
@@ -1502,8 +1502,8 @@ function __wasm_bindgen_func_elem_9895(arg0, arg1, arg2) {
}
}
-function __wasm_bindgen_func_elem_9953(arg0, arg1, arg2, arg3) {
- wasm.__wasm_bindgen_func_elem_9953(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3));
+function __wasm_bindgen_func_elem_9994(arg0, arg1, arg2, arg3) {
+ wasm.__wasm_bindgen_func_elem_9994(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3));
}
const WasmPairingHostRuntimeFinalization = (typeof FinalizationRegistry === 'undefined')
diff --git a/vendor/truapi-host/dist/wasm/testing/truapi_server_bg.wasm b/vendor/truapi-host/dist/wasm/testing/truapi_server_bg.wasm
index ec063458..5566bbef 100644
Binary files a/vendor/truapi-host/dist/wasm/testing/truapi_server_bg.wasm and b/vendor/truapi-host/dist/wasm/testing/truapi_server_bg.wasm differ
diff --git a/vendor/truapi-host/dist/wasm/testing/truapi_server_bg.wasm.d.ts b/vendor/truapi-host/dist/wasm/testing/truapi_server_bg.wasm.d.ts
index e0afc7fe..bb115172 100644
--- a/vendor/truapi-host/dist/wasm/testing/truapi_server_bg.wasm.d.ts
+++ b/vendor/truapi-host/dist/wasm/testing/truapi_server_bg.wasm.d.ts
@@ -68,12 +68,12 @@ export const wasmsigninghostruntime_setGrantAllowancesUnchecked: (a: number, b:
export const wasmsigninghostruntime_setPermissionAuthorizationStatus: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => number;
export const wasmsigninghostruntime_setWithheldResources: (a: number, b: number, c: number) => void;
export const wireSchemaHash: (a: number) => void;
-export const __wasm_bindgen_func_elem_9895: (a: number, b: number, c: number, d: number) => void;
-export const __wasm_bindgen_func_elem_9953: (a: number, b: number, c: number, d: number) => void;
-export const __wasm_bindgen_func_elem_4599: (a: number, b: number, c: number) => void;
-export const __wasm_bindgen_func_elem_4592: (a: number, b: number, c: number) => void;
-export const __wasm_bindgen_func_elem_9776: (a: number, b: number) => void;
-export const __wasm_bindgen_func_elem_4596: (a: number, b: number) => void;
+export const __wasm_bindgen_func_elem_9936: (a: number, b: number, c: number, d: number) => void;
+export const __wasm_bindgen_func_elem_9994: (a: number, b: number, c: number, d: number) => void;
+export const __wasm_bindgen_func_elem_4605: (a: number, b: number, c: number) => void;
+export const __wasm_bindgen_func_elem_4598: (a: number, b: number, c: number) => void;
+export const __wasm_bindgen_func_elem_9817: (a: number, b: number) => void;
+export const __wasm_bindgen_func_elem_4602: (a: number, b: number) => void;
export const __wbindgen_export: (a: number, b: number) => number;
export const __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_export3: (a: number) => void;
diff --git a/vendor/truapi-host/dist/wasm/web/truapi_server.d.ts b/vendor/truapi-host/dist/wasm/web/truapi_server.d.ts
index cd76e604..f74b7dde 100644
--- a/vendor/truapi-host/dist/wasm/web/truapi_server.d.ts
+++ b/vendor/truapi-host/dist/wasm/web/truapi_server.d.ts
@@ -437,12 +437,12 @@ export interface InitOutput {
readonly wasmsigninghostruntime_sessionChatIdentityKey: (a: number, b: number) => void;
readonly wasmsigninghostruntime_setPermissionAuthorizationStatus: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => number;
readonly wireSchemaHash: (a: number) => void;
- readonly __wasm_bindgen_func_elem_9871: (a: number, b: number, c: number, d: number) => void;
- readonly __wasm_bindgen_func_elem_9929: (a: number, b: number, c: number, d: number) => void;
- readonly __wasm_bindgen_func_elem_4527: (a: number, b: number, c: number) => void;
- readonly __wasm_bindgen_func_elem_4520: (a: number, b: number, c: number) => void;
- readonly __wasm_bindgen_func_elem_9752: (a: number, b: number) => void;
- readonly __wasm_bindgen_func_elem_4524: (a: number, b: number) => void;
+ readonly __wasm_bindgen_func_elem_9912: (a: number, b: number, c: number, d: number) => void;
+ readonly __wasm_bindgen_func_elem_9970: (a: number, b: number, c: number, d: number) => void;
+ readonly __wasm_bindgen_func_elem_4533: (a: number, b: number, c: number) => void;
+ readonly __wasm_bindgen_func_elem_4526: (a: number, b: number, c: number) => void;
+ readonly __wasm_bindgen_func_elem_9793: (a: number, b: number) => void;
+ readonly __wasm_bindgen_func_elem_4530: (a: number, b: number) => void;
readonly __wbindgen_export: (a: number, b: number) => number;
readonly __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
readonly __wbindgen_export3: (a: number) => void;
diff --git a/vendor/truapi-host/dist/wasm/web/truapi_server.js b/vendor/truapi-host/dist/wasm/web/truapi_server.js
index 72fb8567..14c40850 100644
--- a/vendor/truapi-host/dist/wasm/web/truapi_server.js
+++ b/vendor/truapi-host/dist/wasm/web/truapi_server.js
@@ -1218,7 +1218,7 @@ function __wbg_get_imports() {
const a = state0.a;
state0.a = 0;
try {
- return __wasm_bindgen_func_elem_9929(a, state0.b, arg0, arg1);
+ return __wasm_bindgen_func_elem_9970(a, state0.b, arg0, arg1);
} finally {
state0.a = a;
}
@@ -1356,27 +1356,27 @@ function __wbg_get_imports() {
},
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 378, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_9871);
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_9912);
return addHeapObject(ret);
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 98, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_4527);
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_4533);
return addHeapObject(ret);
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 165, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_9752);
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_9793);
return addHeapObject(ret);
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 94, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: false }) -> Externref`.
- const ret = makeClosure(arg0, arg1, __wasm_bindgen_func_elem_4520);
+ const ret = makeClosure(arg0, arg1, __wasm_bindgen_func_elem_4526);
return addHeapObject(ret);
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 96, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
- const ret = makeClosure(arg0, arg1, __wasm_bindgen_func_elem_4524);
+ const ret = makeClosure(arg0, arg1, __wasm_bindgen_func_elem_4530);
return addHeapObject(ret);
},
__wbindgen_cast_0000000000000006: function(arg0) {
@@ -1420,22 +1420,22 @@ function __wbg_get_imports() {
};
}
-function __wasm_bindgen_func_elem_9752(arg0, arg1) {
- wasm.__wasm_bindgen_func_elem_9752(arg0, arg1);
+function __wasm_bindgen_func_elem_9793(arg0, arg1) {
+ wasm.__wasm_bindgen_func_elem_9793(arg0, arg1);
}
-function __wasm_bindgen_func_elem_4524(arg0, arg1) {
- wasm.__wasm_bindgen_func_elem_4524(arg0, arg1);
+function __wasm_bindgen_func_elem_4530(arg0, arg1) {
+ wasm.__wasm_bindgen_func_elem_4530(arg0, arg1);
}
-function __wasm_bindgen_func_elem_4527(arg0, arg1, arg2) {
- wasm.__wasm_bindgen_func_elem_4527(arg0, arg1, addHeapObject(arg2));
+function __wasm_bindgen_func_elem_4533(arg0, arg1, arg2) {
+ wasm.__wasm_bindgen_func_elem_4533(arg0, arg1, addHeapObject(arg2));
}
-function __wasm_bindgen_func_elem_4520(arg0, arg1) {
+function __wasm_bindgen_func_elem_4526(arg0, arg1) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
- wasm.__wasm_bindgen_func_elem_4520(retptr, arg0, arg1);
+ wasm.__wasm_bindgen_func_elem_4526(retptr, arg0, arg1);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
if (r1) {
@@ -1446,10 +1446,10 @@ function __wasm_bindgen_func_elem_4520(arg0, arg1) {
}
}
-function __wasm_bindgen_func_elem_9871(arg0, arg1, arg2) {
+function __wasm_bindgen_func_elem_9912(arg0, arg1, arg2) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
- wasm.__wasm_bindgen_func_elem_9871(retptr, arg0, arg1, addHeapObject(arg2));
+ wasm.__wasm_bindgen_func_elem_9912(retptr, arg0, arg1, addHeapObject(arg2));
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
if (r1) {
@@ -1460,8 +1460,8 @@ function __wasm_bindgen_func_elem_9871(arg0, arg1, arg2) {
}
}
-function __wasm_bindgen_func_elem_9929(arg0, arg1, arg2, arg3) {
- wasm.__wasm_bindgen_func_elem_9929(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3));
+function __wasm_bindgen_func_elem_9970(arg0, arg1, arg2, arg3) {
+ wasm.__wasm_bindgen_func_elem_9970(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3));
}
const WasmPairingHostRuntimeFinalization = (typeof FinalizationRegistry === 'undefined')
diff --git a/vendor/truapi-host/dist/wasm/web/truapi_server_bg.wasm b/vendor/truapi-host/dist/wasm/web/truapi_server_bg.wasm
index 18bc739a..fab95413 100644
Binary files a/vendor/truapi-host/dist/wasm/web/truapi_server_bg.wasm and b/vendor/truapi-host/dist/wasm/web/truapi_server_bg.wasm differ
diff --git a/vendor/truapi-host/dist/wasm/web/truapi_server_bg.wasm.d.ts b/vendor/truapi-host/dist/wasm/web/truapi_server_bg.wasm.d.ts
index 534eba94..60b0bd83 100644
--- a/vendor/truapi-host/dist/wasm/web/truapi_server_bg.wasm.d.ts
+++ b/vendor/truapi-host/dist/wasm/web/truapi_server_bg.wasm.d.ts
@@ -65,12 +65,12 @@ export const wasmsigninghostruntime_releaseWorker: (a: number, b: number, c: num
export const wasmsigninghostruntime_sessionChatIdentityKey: (a: number, b: number) => void;
export const wasmsigninghostruntime_setPermissionAuthorizationStatus: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => number;
export const wireSchemaHash: (a: number) => void;
-export const __wasm_bindgen_func_elem_9871: (a: number, b: number, c: number, d: number) => void;
-export const __wasm_bindgen_func_elem_9929: (a: number, b: number, c: number, d: number) => void;
-export const __wasm_bindgen_func_elem_4527: (a: number, b: number, c: number) => void;
-export const __wasm_bindgen_func_elem_4520: (a: number, b: number, c: number) => void;
-export const __wasm_bindgen_func_elem_9752: (a: number, b: number) => void;
-export const __wasm_bindgen_func_elem_4524: (a: number, b: number) => void;
+export const __wasm_bindgen_func_elem_9912: (a: number, b: number, c: number, d: number) => void;
+export const __wasm_bindgen_func_elem_9970: (a: number, b: number, c: number, d: number) => void;
+export const __wasm_bindgen_func_elem_4533: (a: number, b: number, c: number) => void;
+export const __wasm_bindgen_func_elem_4526: (a: number, b: number, c: number) => void;
+export const __wasm_bindgen_func_elem_9793: (a: number, b: number) => void;
+export const __wasm_bindgen_func_elem_4530: (a: number, b: number) => void;
export const __wbindgen_export: (a: number, b: number) => number;
export const __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_export3: (a: number) => void;
diff --git a/vendor/truapi/dist/explorer/codegen/types.js b/vendor/truapi/dist/explorer/codegen/types.js
index 4f629328..a8c9430e 100644
--- a/vendor/truapi/dist/explorer/codegen/types.js
+++ b/vendor/truapi/dist/explorer/codegen/types.js
@@ -2613,6 +2613,329 @@ export const types = [
},
],
},
+ {
+ id: "host-jam-peer-transport-close-error",
+ name: "HostJamPeerTransportCloseError",
+ category: "jam_peer_transport",
+ definition: 'export type HostJamPeerTransportCloseError = "Closed";',
+ description: "Failure to close a connection.",
+ variants: [
+ {
+ name: "Closed",
+ type: '{ tag: "Closed"; value?: undefined }',
+ description: "The connection is unknown or already closed.",
+ },
+ ],
+ },
+ {
+ id: "host-jam-peer-transport-close-request",
+ name: "HostJamPeerTransportCloseRequest",
+ category: "jam_peer_transport",
+ definition: "export interface HostJamPeerTransportCloseRequest {\n conn: number;\n}",
+ description: "Close a connection and every stream on it.",
+ fields: [
+ {
+ name: "conn",
+ type: "number",
+ description: "Connection to close.",
+ },
+ ],
+ },
+ {
+ id: "host-jam-peer-transport-dial-error",
+ name: "HostJamPeerTransportDialError",
+ category: "jam_peer_transport",
+ definition: 'export type HostJamPeerTransportDialError = "NotGranted" | "Refused" | "Limit" | "Unreachable";',
+ description: "Failure to dial a JAM peer.",
+ variants: [
+ {
+ name: "NotGranted",
+ type: '{ tag: "NotGranted"; value?: undefined }',
+ description: "The product holds no `RemotePermission::JamPeers` grant for the\nrequested genesis, or this host offers no peer transport.",
+ },
+ {
+ name: "Refused",
+ type: '{ tag: "Refused"; value?: undefined }',
+ description: "The peer refused the connection or presented a certificate that does\nnot match the requested identity.",
+ },
+ {
+ name: "Limit",
+ type: '{ tag: "Limit"; value?: undefined }',
+ description: "The connection cap for this execution is exhausted.",
+ },
+ {
+ name: "Unreachable",
+ type: '{ tag: "Unreachable"; value?: undefined }',
+ description: "The endpoint could not be reached.",
+ },
+ ],
+ },
+ {
+ id: "host-jam-peer-transport-dial-request",
+ name: "HostJamPeerTransportDialRequest",
+ category: "jam_peer_transport",
+ definition: "export interface HostJamPeerTransportDialRequest {\n genesis: HexString;\n ip: HexString;\n port: number;\n ed25519: HexString;\n p256?: HexString;\n}",
+ description: "Dial one JAM peer over JAMNP-S (QUIC) or WebTransport.",
+ fields: [
+ {
+ name: "genesis",
+ type: "HexString",
+ description: "Genesis header hash; the host derives the ALPN from it and requires a\n`RemotePermission::JamPeers` grant for it.",
+ },
+ {
+ name: "ip",
+ type: "HexString",
+ description: "Peer IP address, IPv6 or v4-mapped IPv6.",
+ },
+ {
+ name: "port",
+ type: "number",
+ description: "Peer UDP port.",
+ },
+ {
+ name: "ed25519",
+ type: "HexString",
+ description: "Ed25519 key the peer's TLS certificate must carry.",
+ },
+ {
+ name: "p256",
+ type: "HexString | undefined",
+ description: "Compressed P-256 peer key for WebTransport certificate hashes.",
+ },
+ ],
+ },
+ {
+ id: "host-jam-peer-transport-dial-response",
+ name: "HostJamPeerTransportDialResponse",
+ category: "jam_peer_transport",
+ definition: "export interface HostJamPeerTransportDialResponse {\n conn: number;\n}",
+ description: "An open connection handle.",
+ fields: [
+ {
+ name: "conn",
+ type: "number",
+ description: "Execution-local connection id.",
+ },
+ ],
+ },
+ {
+ id: "host-jam-peer-transport-events-error",
+ name: "HostJamPeerTransportEventsError",
+ category: "jam_peer_transport",
+ definition: 'export type HostJamPeerTransportEventsError = "NotGranted";',
+ description: "Failure to drain events.",
+ variants: [
+ {
+ name: "NotGranted",
+ type: '{ tag: "NotGranted"; value?: undefined }',
+ description: "This execution has no peer-transport grant.",
+ },
+ ],
+ },
+ {
+ id: "host-jam-peer-transport-events-response",
+ name: "HostJamPeerTransportEventsResponse",
+ category: "jam_peer_transport",
+ definition: "export interface HostJamPeerTransportEventsResponse {\n events: Array;\n}",
+ description: "Events in arrival order.",
+ fields: [
+ {
+ name: "events",
+ type: "Array",
+ description: "Pending events; empty when nothing happened.",
+ },
+ ],
+ },
+ {
+ id: "host-jam-peer-transport-open-error",
+ name: "HostJamPeerTransportOpenError",
+ category: "jam_peer_transport",
+ definition: 'export type HostJamPeerTransportOpenError = "NotGranted" | "Closed" | "Limit";',
+ description: "Failure to open a stream.",
+ variants: [
+ {
+ name: "NotGranted",
+ type: '{ tag: "NotGranted"; value?: undefined }',
+ description: "This execution has no peer-transport grant.",
+ },
+ {
+ name: "Closed",
+ type: '{ tag: "Closed"; value?: undefined }',
+ description: "The connection is closed or unknown.",
+ },
+ {
+ name: "Limit",
+ type: '{ tag: "Limit"; value?: undefined }',
+ description: "The stream cap for this connection is exhausted.",
+ },
+ ],
+ },
+ {
+ id: "host-jam-peer-transport-open-request",
+ name: "HostJamPeerTransportOpenRequest",
+ category: "jam_peer_transport",
+ definition: "export interface HostJamPeerTransportOpenRequest {\n conn: number;\n kind: number;\n}",
+ description: "Open a bidirectional stream and send its kind byte.",
+ fields: [
+ {
+ name: "conn",
+ type: "number",
+ description: "Connection returned by `dial`.",
+ },
+ {
+ name: "kind",
+ type: "number",
+ description: "JAMNP-S stream kind (UP 0, CE 128, ...).",
+ },
+ ],
+ },
+ {
+ id: "host-jam-peer-transport-open-response",
+ name: "HostJamPeerTransportOpenResponse",
+ category: "jam_peer_transport",
+ definition: "export interface HostJamPeerTransportOpenResponse {\n stream: number;\n}",
+ description: "An open stream handle.",
+ fields: [
+ {
+ name: "stream",
+ type: "number",
+ description: "Execution-local stream id.",
+ },
+ ],
+ },
+ {
+ id: "host-jam-peer-transport-recv-error",
+ name: "HostJamPeerTransportRecvError",
+ category: "jam_peer_transport",
+ definition: 'export type HostJamPeerTransportRecvError = "Closed";',
+ description: "Failure to receive from a stream.",
+ variants: [
+ {
+ name: "Closed",
+ type: '{ tag: "Closed"; value?: undefined }',
+ description: "The stream is unknown or already fully consumed.",
+ },
+ ],
+ },
+ {
+ id: "host-jam-peer-transport-recv-request",
+ name: "HostJamPeerTransportRecvRequest",
+ category: "jam_peer_transport",
+ definition: "export interface HostJamPeerTransportRecvRequest {\n stream: number;\n max: number;\n}",
+ description: "Poll one complete framed message without blocking.",
+ fields: [
+ {
+ name: "stream",
+ type: "number",
+ description: "Stream to read from.",
+ },
+ {
+ name: "max",
+ type: "number",
+ description: "Largest message the caller accepts.",
+ },
+ ],
+ },
+ {
+ id: "host-jam-peer-transport-recv-response",
+ name: "HostJamPeerTransportRecvResponse",
+ category: "jam_peer_transport",
+ definition: "export interface HostJamPeerTransportRecvResponse {\n message?: HexString;\n fin: boolean;\n reset: boolean;\n}",
+ description: "One unframed message, or none available yet.",
+ fields: [
+ {
+ name: "message",
+ type: "HexString | undefined",
+ description: "Complete message bytes without length prefix, or `None` when nothing\nhas arrived yet.",
+ },
+ {
+ name: "fin",
+ type: "boolean",
+ description: "The peer finished its send side; no further messages will arrive.",
+ },
+ {
+ name: "reset",
+ type: "boolean",
+ description: "The peer reset the stream; buffered data may be incomplete.",
+ },
+ ],
+ },
+ {
+ id: "host-jam-peer-transport-reset-error",
+ name: "HostJamPeerTransportResetError",
+ category: "jam_peer_transport",
+ definition: 'export type HostJamPeerTransportResetError = "Closed";',
+ description: "Failure to reset a stream.",
+ variants: [
+ {
+ name: "Closed",
+ type: '{ tag: "Closed"; value?: undefined }',
+ description: "The stream is unknown or already closed.",
+ },
+ ],
+ },
+ {
+ id: "host-jam-peer-transport-reset-request",
+ name: "HostJamPeerTransportResetRequest",
+ category: "jam_peer_transport",
+ definition: "export interface HostJamPeerTransportResetRequest {\n stream: number;\n}",
+ description: "Abort both directions of a stream.",
+ fields: [
+ {
+ name: "stream",
+ type: "number",
+ description: "Stream to reset.",
+ },
+ ],
+ },
+ {
+ id: "host-jam-peer-transport-send-error",
+ name: "HostJamPeerTransportSendError",
+ category: "jam_peer_transport",
+ definition: 'export type HostJamPeerTransportSendError = "Closed" | "TooLarge" | "Limit";',
+ description: "Failure to send a message.",
+ variants: [
+ {
+ name: "Closed",
+ type: '{ tag: "Closed"; value?: undefined }',
+ description: "The stream is closed, finished or unknown.",
+ },
+ {
+ name: "TooLarge",
+ type: '{ tag: "TooLarge"; value?: undefined }',
+ description: "The message exceeds the host's message limit.",
+ },
+ {
+ name: "Limit",
+ type: '{ tag: "Limit"; value?: undefined }',
+ description: "The per-connection buffer is full.",
+ },
+ ],
+ },
+ {
+ id: "host-jam-peer-transport-send-request",
+ name: "HostJamPeerTransportSendRequest",
+ category: "jam_peer_transport",
+ definition: "export interface HostJamPeerTransportSendRequest {\n stream: number;\n message: HexString;\n fin: boolean;\n}",
+ description: "Send one framed message; the host adds the `u32` little-endian length.",
+ fields: [
+ {
+ name: "stream",
+ type: "number",
+ description: "Stream returned by `open` or reported by an `Accepted` event.",
+ },
+ {
+ name: "message",
+ type: "HexString",
+ description: "Message bytes without length prefix.",
+ },
+ {
+ name: "fin",
+ type: "boolean",
+ description: "Finish the send side after this message.",
+ },
+ ],
+ },
{
id: "host-local-storage-change-item",
name: "HostLocalStorageChangeItem",
@@ -4862,6 +5185,30 @@ export const types = [
},
],
},
+ {
+ id: "jam-peer-transport-event",
+ name: "JamPeerTransportEvent",
+ category: "jam_peer_transport",
+ definition: 'export type JamPeerTransportEvent =\n | { tag: "ConnClosed"; value: { conn: number } }\n | { tag: "StreamFin"; value: { stream: number } }\n | { tag: "Accepted"; value: { conn: number; stream: number; kind: number } }\n;',
+ description: "Asynchronous transport notification.",
+ variants: [
+ {
+ name: "ConnClosed",
+ type: '{ tag: "ConnClosed"; value: { conn: number } }',
+ description: "The connection was closed by the peer or the host.",
+ },
+ {
+ name: "StreamFin",
+ type: '{ tag: "StreamFin"; value: { stream: number } }',
+ description: "The peer finished its send side of a stream.",
+ },
+ {
+ name: "Accepted",
+ type: '{ tag: "Accepted"; value: { conn: number; stream: number; kind: number } }',
+ description: "The peer opened a stream to us on a dialed connection.",
+ },
+ ],
+ },
{
id: "legacy-account",
name: "LegacyAccount",
@@ -5809,8 +6156,8 @@ export const types = [
id: "remote-permission",
name: "RemotePermission",
category: "permissions",
- definition: 'export type RemotePermission =\n | { tag: "Remote"; value: { domains: Array } }\n | { tag: "WebRtc"; value?: undefined }\n | { tag: "ChainSubmit"; value?: undefined }\n | { tag: "PreimageSubmit"; value?: undefined }\n | { tag: "StatementSubmit"; value?: undefined }\n;',
- description: "One remote-operation permission requested by the product (RFC 0002).\n\n`ChainSubmit`, `PreimageSubmit`, and `StatementSubmit` are also triggered\nimplicitly by the corresponding business calls when not yet granted.",
+ definition: 'export type RemotePermission =\n | { tag: "Remote"; value: { domains: Array } }\n | { tag: "WebRtc"; value?: undefined }\n | { tag: "ChainSubmit"; value?: undefined }\n | { tag: "PreimageSubmit"; value?: undefined }\n | { tag: "StatementSubmit"; value?: undefined }\n | { tag: "JamPeers"; value: { genesis: HexString } }\n;',
+ description: "One remote-operation permission requested by the product (RFC 0002).\n\n`ChainSubmit`, `PreimageSubmit`, `StatementSubmit` and `JamPeers` are also\ntriggered implicitly by the corresponding business calls when not yet\ngranted (`JamPeerTransport::dial` for `JamPeers`).",
variants: [
{
name: "Remote",
@@ -5837,6 +6184,11 @@ export const types = [
type: '{ tag: "StatementSubmit"; value?: undefined }',
description: "Submitting statements on behalf of the user via `remote_statement_store_submit`.",
},
+ {
+ name: "JamPeers",
+ type: '{ tag: "JamPeers"; value: { genesis: HexString } }',
+ description: "Read-only peer access over JAMNP-S QUIC/WebTransport to the validators\nof one JAM chain, through the `JamPeerTransport` service.\n\nThe app names the endpoints it dials; the grant covers only peers of\n`genesis`. Every byte received is untrusted, and the grant carries no\naccount, signing or submission authority.",
+ },
],
},
{
diff --git a/vendor/truapi/dist/generated/client.d.ts b/vendor/truapi/dist/generated/client.d.ts
index 0218137d..a8409555 100644
--- a/vendor/truapi/dist/generated/client.d.ts
+++ b/vendor/truapi/dist/generated/client.d.ts
@@ -8,7 +8,7 @@ export { ResultAsync, SubscriptionError };
export type { CallOptions, HostInitiatedSubscriptionHandler, ObservableLike, Observer, Result, Subscription, TrUApiTransport };
export declare const TRUAPI_VERSION: 3;
export declare const TRUAPI_CODEC_VERSION: 3;
-export declare const TRUAPI_WIRE_SCHEMA_HASH: "6bbdd3b23a6400bb";
+export declare const TRUAPI_WIRE_SCHEMA_HASH: "4dda7fbab9d6f435";
/** Account lookup, aliasing, and proof generation. */
export declare class AccountClient {
#private;
@@ -234,6 +234,40 @@ export declare class EntropyClient {
/** Derive deterministic entropy. */
derive(request: T.HostDeriveEntropyRequest, options?: CallOptions): ResultAsync>;
}
+/**
+ * Host-terminated QUIC/WebTransport streams to JAM peers (JAMNP-S).
+ *
+ * The host owns TLS, certificate verification and length framing; the guest
+ * verifies every byte it consumes. Access is a runtime permission, not a
+ * manifest declaration: `dial` requires
+ * [`RemotePermission::JamPeers`](crate::v01::RemotePermission::JamPeers) for
+ * its `genesis`, checking the product's stored decision, prompting when it is
+ * undetermined and persisting the answer per product and genesis. The other
+ * methods act only on connections a granted `dial` opened. A grant is
+ * separate from account, signing and storage authority.
+ */
+export declare class JamPeerTransportClient {
+ #private;
+ constructor(transport: TrUApiTransport);
+ /**
+ * Dial one peer. The host builds the ALPN from `genesis` and requires the
+ * peer certificate to carry `ed25519` (QUIC) or to hash to the
+ * certificate derived from `p256` (WebTransport).
+ */
+ dial(request: T.HostJamPeerTransportDialRequest, options?: CallOptions): ResultAsync>;
+ /** Open a bidirectional stream on a connection and send its kind byte. */
+ open(request: T.HostJamPeerTransportOpenRequest, options?: CallOptions): ResultAsync>;
+ /** Queue one message; the host prepends the `u32` little-endian length. */
+ send(request: T.HostJamPeerTransportSendRequest, options?: CallOptions): ResultAsync>;
+ /** Poll one complete message without blocking; the host strips the length. */
+ recv(request: T.HostJamPeerTransportRecvRequest, options?: CallOptions): ResultAsync>;
+ /** Abort a stream in both directions. */
+ reset(request: T.HostJamPeerTransportResetRequest, options?: CallOptions): ResultAsync>;
+ /** Close a connection and every stream on it. */
+ close(request: T.HostJamPeerTransportCloseRequest, options?: CallOptions): ResultAsync>;
+ /** Drain connection, stream-finish and inbound-stream events. */
+ events(options?: CallOptions): ResultAsync>;
+}
/** Local key/value storage scoped to the calling product. */
export declare class LocalStorageClient {
#private;
@@ -630,6 +664,7 @@ export interface TrUApiClient {
readonly coinPayment: CoinPaymentClient;
readonly contacts: ContactsClient;
readonly entropy: EntropyClient;
+ readonly jamPeerTransport: JamPeerTransportClient;
readonly localStorage: LocalStorageClient;
readonly locale: LocaleClient;
readonly notifications: NotificationsClient;
diff --git a/vendor/truapi/dist/generated/client.js b/vendor/truapi/dist/generated/client.js
index 430c7b37..64c72ec3 100644
--- a/vendor/truapi/dist/generated/client.js
+++ b/vendor/truapi/dist/generated/client.js
@@ -7,7 +7,7 @@ import * as W from './wire-table.js';
export { ResultAsync, SubscriptionError };
export const TRUAPI_VERSION = 3;
export const TRUAPI_CODEC_VERSION = 3;
-export const TRUAPI_WIRE_SCHEMA_HASH = "6bbdd3b23a6400bb";
+export const TRUAPI_WIRE_SCHEMA_HASH = "4dda7fbab9d6f435";
function toSubscriptionError(error) {
if (error instanceof SubscriptionError)
return error;
@@ -787,6 +787,112 @@ export class EntropyClient {
});
}
}
+/**
+ * Host-terminated QUIC/WebTransport streams to JAM peers (JAMNP-S).
+ *
+ * The host owns TLS, certificate verification and length framing; the guest
+ * verifies every byte it consumes. Access is a runtime permission, not a
+ * manifest declaration: `dial` requires
+ * [`RemotePermission::JamPeers`](crate::v01::RemotePermission::JamPeers) for
+ * its `genesis`, checking the product's stored decision, prompting when it is
+ * undetermined and persisting the answer per product and genesis. The other
+ * methods act only on connections a granted `dial` opened. A grant is
+ * separate from account, signing and storage authority.
+ */
+export class JamPeerTransportClient {
+ #transport;
+ constructor(transport) {
+ this.#transport = transport;
+ }
+ /**
+ * Dial one peer. The host builds the ALPN from `genesis` and requires the
+ * peer certificate to carry `ed25519` (QUIC) or to hash to the
+ * certificate derived from `p256` (WebTransport).
+ */
+ dial(request, options) {
+ return this.#transport.request({
+ ids: W.JAM_PEER_TRANSPORT_DIAL,
+ payload: T.VersionedHostJamPeerTransportDialRequest.enc({ tag: "V1", value: request }),
+ signal: options?.signal,
+ decodeResponse: (payload) => {
+ const result = S.Result(T.VersionedHostJamPeerTransportDialResponse, S.CallError(T.VersionedHostJamPeerTransportDialError)).dec(payload);
+ return result.success ? { success: true, value: result.value.value } : result;
+ },
+ });
+ }
+ /** Open a bidirectional stream on a connection and send its kind byte. */
+ open(request, options) {
+ return this.#transport.request({
+ ids: W.JAM_PEER_TRANSPORT_OPEN,
+ payload: T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: request }),
+ signal: options?.signal,
+ decodeResponse: (payload) => {
+ const result = S.Result(T.VersionedHostJamPeerTransportOpenResponse, S.CallError(T.VersionedHostJamPeerTransportOpenError)).dec(payload);
+ return result.success ? { success: true, value: result.value.value } : result;
+ },
+ });
+ }
+ /** Queue one message; the host prepends the `u32` little-endian length. */
+ send(request, options) {
+ return this.#transport.request({
+ ids: W.JAM_PEER_TRANSPORT_SEND,
+ payload: T.VersionedHostJamPeerTransportSendRequest.enc({ tag: "V1", value: request }),
+ signal: options?.signal,
+ decodeResponse: (payload) => {
+ const result = S.Result(T.VersionedHostJamPeerTransportSendResponse, S.CallError(T.VersionedHostJamPeerTransportSendError)).dec(payload);
+ return result.success ? { success: true, value: result.value.value } : result;
+ },
+ });
+ }
+ /** Poll one complete message without blocking; the host strips the length. */
+ recv(request, options) {
+ return this.#transport.request({
+ ids: W.JAM_PEER_TRANSPORT_RECV,
+ payload: T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: request }),
+ signal: options?.signal,
+ decodeResponse: (payload) => {
+ const result = S.Result(T.VersionedHostJamPeerTransportRecvResponse, S.CallError(T.VersionedHostJamPeerTransportRecvError)).dec(payload);
+ return result.success ? { success: true, value: result.value.value } : result;
+ },
+ });
+ }
+ /** Abort a stream in both directions. */
+ reset(request, options) {
+ return this.#transport.request({
+ ids: W.JAM_PEER_TRANSPORT_RESET,
+ payload: T.VersionedHostJamPeerTransportResetRequest.enc({ tag: "V1", value: request }),
+ signal: options?.signal,
+ decodeResponse: (payload) => {
+ const result = S.Result(T.VersionedHostJamPeerTransportResetResponse, S.CallError(T.VersionedHostJamPeerTransportResetError)).dec(payload);
+ return result.success ? { success: true, value: result.value.value } : result;
+ },
+ });
+ }
+ /** Close a connection and every stream on it. */
+ close(request, options) {
+ return this.#transport.request({
+ ids: W.JAM_PEER_TRANSPORT_CLOSE,
+ payload: T.VersionedHostJamPeerTransportCloseRequest.enc({ tag: "V1", value: request }),
+ signal: options?.signal,
+ decodeResponse: (payload) => {
+ const result = S.Result(T.VersionedHostJamPeerTransportCloseResponse, S.CallError(T.VersionedHostJamPeerTransportCloseError)).dec(payload);
+ return result.success ? { success: true, value: result.value.value } : result;
+ },
+ });
+ }
+ /** Drain connection, stream-finish and inbound-stream events. */
+ events(options) {
+ return this.#transport.request({
+ ids: W.JAM_PEER_TRANSPORT_EVENTS,
+ payload: T.VersionedHostJamPeerTransportEventsRequest.enc({ tag: "V1", value: undefined }),
+ signal: options?.signal,
+ decodeResponse: (payload) => {
+ const result = S.Result(T.VersionedHostJamPeerTransportEventsResponse, S.CallError(T.VersionedHostJamPeerTransportEventsError)).dec(payload);
+ return result.success ? { success: true, value: result.value.value } : result;
+ },
+ });
+ }
+}
/** Local key/value storage scoped to the calling product. */
export class LocalStorageClient {
#transport;
@@ -1667,6 +1773,7 @@ export function createClient(transport) {
coinPayment: new CoinPaymentClient(transport),
contacts: new ContactsClient(transport),
entropy: new EntropyClient(transport),
+ jamPeerTransport: new JamPeerTransportClient(transport),
localStorage: new LocalStorageClient(transport),
locale: new LocaleClient(transport),
notifications: new NotificationsClient(transport),
diff --git a/vendor/truapi/dist/generated/internal-client.d.ts b/vendor/truapi/dist/generated/internal-client.d.ts
index e611d72f..5d9eab35 100644
--- a/vendor/truapi/dist/generated/internal-client.d.ts
+++ b/vendor/truapi/dist/generated/internal-client.d.ts
@@ -7,7 +7,7 @@ export { ResultAsync, SubscriptionError };
export type { CallOptions, HostInitiatedSubscriptionHandler, ObservableLike, Observer, Result, Subscription, TrUApiTransport };
export declare const TRUAPI_VERSION: 3;
export declare const TRUAPI_CODEC_VERSION: 3;
-export declare const TRUAPI_WIRE_SCHEMA_HASH: "6bbdd3b23a6400bb";
+export declare const TRUAPI_WIRE_SCHEMA_HASH: "4dda7fbab9d6f435";
/** Permission request methods. */
declare class PermissionsClient {
#private;
diff --git a/vendor/truapi/dist/generated/internal-client.js b/vendor/truapi/dist/generated/internal-client.js
index 84562fe2..e3911a38 100644
--- a/vendor/truapi/dist/generated/internal-client.js
+++ b/vendor/truapi/dist/generated/internal-client.js
@@ -7,7 +7,7 @@ import * as W from './wire-table.js';
export { ResultAsync, SubscriptionError };
export const TRUAPI_VERSION = 3;
export const TRUAPI_CODEC_VERSION = 3;
-export const TRUAPI_WIRE_SCHEMA_HASH = "6bbdd3b23a6400bb";
+export const TRUAPI_WIRE_SCHEMA_HASH = "4dda7fbab9d6f435";
/** Permission request methods. */
class PermissionsClient {
#transport;
diff --git a/vendor/truapi/dist/generated/types.d.ts b/vendor/truapi/dist/generated/types.d.ts
index 010047b3..10d69cd2 100644
--- a/vendor/truapi/dist/generated/types.d.ts
+++ b/vendor/truapi/dist/generated/types.d.ts
@@ -1376,6 +1376,174 @@ export type VersionedHostInfoResponse =
value: HostInfo;
};
export declare const VersionedHostInfoResponse: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportCloseError`]. */
+export type VersionedHostJamPeerTransportCloseError =
+/** Version 1 payload. */
+{
+ tag: "V1";
+ value: HostJamPeerTransportCloseError;
+};
+export declare const VersionedHostJamPeerTransportCloseError: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportCloseRequest`]. */
+export type VersionedHostJamPeerTransportCloseRequest =
+/** Version 1 payload. */
+{
+ tag: "V1";
+ value: HostJamPeerTransportCloseRequest;
+};
+export declare const VersionedHostJamPeerTransportCloseRequest: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportCloseResponse`]. */
+export type VersionedHostJamPeerTransportCloseResponse =
+/** Version 1 (no payload). */
+{
+ tag: "V1";
+ value?: undefined;
+};
+export declare const VersionedHostJamPeerTransportCloseResponse: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportDialError`]. */
+export type VersionedHostJamPeerTransportDialError =
+/** Version 1 payload. */
+{
+ tag: "V1";
+ value: HostJamPeerTransportDialError;
+};
+export declare const VersionedHostJamPeerTransportDialError: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportDialRequest`]. */
+export type VersionedHostJamPeerTransportDialRequest =
+/** Version 1 payload. */
+{
+ tag: "V1";
+ value: HostJamPeerTransportDialRequest;
+};
+export declare const VersionedHostJamPeerTransportDialRequest: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportDialResponse`]. */
+export type VersionedHostJamPeerTransportDialResponse =
+/** Version 1 payload. */
+{
+ tag: "V1";
+ value: HostJamPeerTransportDialResponse;
+};
+export declare const VersionedHostJamPeerTransportDialResponse: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportEventsError`]. */
+export type VersionedHostJamPeerTransportEventsError =
+/** Version 1 payload. */
+{
+ tag: "V1";
+ value: HostJamPeerTransportEventsError;
+};
+export declare const VersionedHostJamPeerTransportEventsError: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportEventsRequest`]. */
+export type VersionedHostJamPeerTransportEventsRequest =
+/** Version 1 (no payload). */
+{
+ tag: "V1";
+ value?: undefined;
+};
+export declare const VersionedHostJamPeerTransportEventsRequest: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportEventsResponse`]. */
+export type VersionedHostJamPeerTransportEventsResponse =
+/** Version 1 payload. */
+{
+ tag: "V1";
+ value: HostJamPeerTransportEventsResponse;
+};
+export declare const VersionedHostJamPeerTransportEventsResponse: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportOpenError`]. */
+export type VersionedHostJamPeerTransportOpenError =
+/** Version 1 payload. */
+{
+ tag: "V1";
+ value: HostJamPeerTransportOpenError;
+};
+export declare const VersionedHostJamPeerTransportOpenError: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportOpenRequest`]. */
+export type VersionedHostJamPeerTransportOpenRequest =
+/** Version 1 payload. */
+{
+ tag: "V1";
+ value: HostJamPeerTransportOpenRequest;
+};
+export declare const VersionedHostJamPeerTransportOpenRequest: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportOpenResponse`]. */
+export type VersionedHostJamPeerTransportOpenResponse =
+/** Version 1 payload. */
+{
+ tag: "V1";
+ value: HostJamPeerTransportOpenResponse;
+};
+export declare const VersionedHostJamPeerTransportOpenResponse: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportRecvError`]. */
+export type VersionedHostJamPeerTransportRecvError =
+/** Version 1 payload. */
+{
+ tag: "V1";
+ value: HostJamPeerTransportRecvError;
+};
+export declare const VersionedHostJamPeerTransportRecvError: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportRecvRequest`]. */
+export type VersionedHostJamPeerTransportRecvRequest =
+/** Version 1 payload. */
+{
+ tag: "V1";
+ value: HostJamPeerTransportRecvRequest;
+};
+export declare const VersionedHostJamPeerTransportRecvRequest: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportRecvResponse`]. */
+export type VersionedHostJamPeerTransportRecvResponse =
+/** Version 1 payload. */
+{
+ tag: "V1";
+ value: HostJamPeerTransportRecvResponse;
+};
+export declare const VersionedHostJamPeerTransportRecvResponse: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportResetError`]. */
+export type VersionedHostJamPeerTransportResetError =
+/** Version 1 payload. */
+{
+ tag: "V1";
+ value: HostJamPeerTransportResetError;
+};
+export declare const VersionedHostJamPeerTransportResetError: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportResetRequest`]. */
+export type VersionedHostJamPeerTransportResetRequest =
+/** Version 1 payload. */
+{
+ tag: "V1";
+ value: HostJamPeerTransportResetRequest;
+};
+export declare const VersionedHostJamPeerTransportResetRequest: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportResetResponse`]. */
+export type VersionedHostJamPeerTransportResetResponse =
+/** Version 1 (no payload). */
+{
+ tag: "V1";
+ value?: undefined;
+};
+export declare const VersionedHostJamPeerTransportResetResponse: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportSendError`]. */
+export type VersionedHostJamPeerTransportSendError =
+/** Version 1 payload. */
+{
+ tag: "V1";
+ value: HostJamPeerTransportSendError;
+};
+export declare const VersionedHostJamPeerTransportSendError: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportSendRequest`]. */
+export type VersionedHostJamPeerTransportSendRequest =
+/** Version 1 payload. */
+{
+ tag: "V1";
+ value: HostJamPeerTransportSendRequest;
+};
+export declare const VersionedHostJamPeerTransportSendRequest: S.Codec;
+/** Versioned envelope for [`HostJamPeerTransportSendResponse`]. */
+export type VersionedHostJamPeerTransportSendResponse =
+/** Version 1 (no payload). */
+{
+ tag: "V1";
+ value?: undefined;
+};
+export declare const VersionedHostJamPeerTransportSendResponse: S.Codec;
/** Versioned envelope for [`HostLocalStorageChangeItem`]. */
export type VersionedHostLocalStorageChangeItem =
/** Version 1 payload. */
@@ -2596,6 +2764,32 @@ export type ImageSource =
value: string;
};
export declare const ImageSource: S.Codec;
+/** Asynchronous transport notification. */
+export type JamPeerTransportEvent =
+/** The connection was closed by the peer or the host. */
+{
+ tag: "ConnClosed";
+ value: {
+ conn: number;
+ };
+}
+/** The peer finished its send side of a stream. */
+ | {
+ tag: "StreamFin";
+ value: {
+ stream: number;
+ };
+}
+/** The peer opened a stream to us on a dialed connection. */
+ | {
+ tag: "Accepted";
+ value: {
+ conn: number;
+ stream: number;
+ kind: number;
+ };
+};
+export declare const JamPeerTransportEvent: S.Codec;
/**
* A user-imported (legacy) account: public key plus an optional user-chosen
* display name.
@@ -3271,8 +3465,9 @@ export declare const VersionedRemoteChainTransactionStopResponse: S.Codec;
/** Versioned envelope for [`RemotePermissionError`]. */
@@ -4667,6 +4876,113 @@ export interface HostHandshakeRequest {
codecVersion: number;
}
export declare const HostHandshakeRequest: S.Codec;
+/** Failure to close a connection. */
+export type HostJamPeerTransportCloseError = "Closed";
+export declare const HostJamPeerTransportCloseError: S.Codec;
+/** Close a connection and every stream on it. */
+export interface HostJamPeerTransportCloseRequest {
+ /** Connection to close. */
+ conn: number;
+}
+export declare const HostJamPeerTransportCloseRequest: S.Codec;
+/** Failure to dial a JAM peer. */
+export type HostJamPeerTransportDialError = "NotGranted" | "Refused" | "Limit" | "Unreachable";
+export declare const HostJamPeerTransportDialError: S.Codec;
+/** Dial one JAM peer over JAMNP-S (QUIC) or WebTransport. */
+export interface HostJamPeerTransportDialRequest {
+ /**
+ * Genesis header hash; the host derives the ALPN from it and requires a
+ * `RemotePermission::JamPeers` grant for it.
+ */
+ genesis: HexString;
+ /** Peer IP address, IPv6 or v4-mapped IPv6. */
+ ip: HexString;
+ /** Peer UDP port. */
+ port: number;
+ /** Ed25519 key the peer's TLS certificate must carry. */
+ ed25519: HexString;
+ /** Compressed P-256 peer key for WebTransport certificate hashes. */
+ p256?: HexString;
+}
+export declare const HostJamPeerTransportDialRequest: S.Codec;
+/** An open connection handle. */
+export interface HostJamPeerTransportDialResponse {
+ /** Execution-local connection id. */
+ conn: number;
+}
+export declare const HostJamPeerTransportDialResponse: S.Codec;
+/** Failure to drain events. */
+export type HostJamPeerTransportEventsError = "NotGranted";
+export declare const HostJamPeerTransportEventsError: S.Codec;
+/** Events in arrival order. */
+export interface HostJamPeerTransportEventsResponse {
+ /** Pending events; empty when nothing happened. */
+ events: Array;
+}
+export declare const HostJamPeerTransportEventsResponse: S.Codec;
+/** Failure to open a stream. */
+export type HostJamPeerTransportOpenError = "NotGranted" | "Closed" | "Limit";
+export declare const HostJamPeerTransportOpenError: S.Codec;
+/** Open a bidirectional stream and send its kind byte. */
+export interface HostJamPeerTransportOpenRequest {
+ /** Connection returned by `dial`. */
+ conn: number;
+ /** JAMNP-S stream kind (UP 0, CE 128, ...). */
+ kind: number;
+}
+export declare const HostJamPeerTransportOpenRequest: S.Codec;
+/** An open stream handle. */
+export interface HostJamPeerTransportOpenResponse {
+ /** Execution-local stream id. */
+ stream: number;
+}
+export declare const HostJamPeerTransportOpenResponse: S.Codec;
+/** Failure to receive from a stream. */
+export type HostJamPeerTransportRecvError = "Closed";
+export declare const HostJamPeerTransportRecvError: S.Codec;
+/** Poll one complete framed message without blocking. */
+export interface HostJamPeerTransportRecvRequest {
+ /** Stream to read from. */
+ stream: number;
+ /** Largest message the caller accepts. */
+ max: number;
+}
+export declare const HostJamPeerTransportRecvRequest: S.Codec;
+/** One unframed message, or none available yet. */
+export interface HostJamPeerTransportRecvResponse {
+ /**
+ * Complete message bytes without length prefix, or `None` when nothing
+ * has arrived yet.
+ */
+ message?: HexString;
+ /** The peer finished its send side; no further messages will arrive. */
+ fin: boolean;
+ /** The peer reset the stream; buffered data may be incomplete. */
+ reset: boolean;
+}
+export declare const HostJamPeerTransportRecvResponse: S.Codec;
+/** Failure to reset a stream. */
+export type HostJamPeerTransportResetError = "Closed";
+export declare const HostJamPeerTransportResetError: S.Codec;
+/** Abort both directions of a stream. */
+export interface HostJamPeerTransportResetRequest {
+ /** Stream to reset. */
+ stream: number;
+}
+export declare const HostJamPeerTransportResetRequest: S.Codec;
+/** Failure to send a message. */
+export type HostJamPeerTransportSendError = "Closed" | "TooLarge" | "Limit";
+export declare const HostJamPeerTransportSendError: S.Codec;
+/** Send one framed message; the host adds the `u32` little-endian length. */
+export interface HostJamPeerTransportSendRequest {
+ /** Stream returned by `open` or reported by an `Accepted` event. */
+ stream: number;
+ /** Message bytes without length prefix. */
+ message: HexString;
+ /** Finish the send side after this message. */
+ fin: boolean;
+}
+export declare const HostJamPeerTransportSendRequest: S.Codec;
/** A change to a subscribed storage key, pushed to the subscriber. */
export interface HostLocalStorageChangeItem {
/** Value after the change. `Some` on write, `None` after clear. */
diff --git a/vendor/truapi/dist/generated/types.js b/vendor/truapi/dist/generated/types.js
index 1c4c6ee4..88b27bc0 100644
--- a/vendor/truapi/dist/generated/types.js
+++ b/vendor/truapi/dist/generated/types.js
@@ -154,6 +154,27 @@ export const HostInfo = S.lazy(() => S.Struct({ platform: HostPlatform, name: S.
export const VersionedHostInfoError = S.lazy(() => S.indexedTaggedUnion({ V1: [0, GenericError] }));
export const VersionedHostInfoRequest = S.lazy(() => S.indexedTaggedUnion({ V1: [0, S._void] }));
export const VersionedHostInfoResponse = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostInfo] }));
+export const VersionedHostJamPeerTransportCloseError = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostJamPeerTransportCloseError] }));
+export const VersionedHostJamPeerTransportCloseRequest = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostJamPeerTransportCloseRequest] }));
+export const VersionedHostJamPeerTransportCloseResponse = S.lazy(() => S.indexedTaggedUnion({ V1: [0, S._void] }));
+export const VersionedHostJamPeerTransportDialError = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostJamPeerTransportDialError] }));
+export const VersionedHostJamPeerTransportDialRequest = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostJamPeerTransportDialRequest] }));
+export const VersionedHostJamPeerTransportDialResponse = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostJamPeerTransportDialResponse] }));
+export const VersionedHostJamPeerTransportEventsError = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostJamPeerTransportEventsError] }));
+export const VersionedHostJamPeerTransportEventsRequest = S.lazy(() => S.indexedTaggedUnion({ V1: [0, S._void] }));
+export const VersionedHostJamPeerTransportEventsResponse = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostJamPeerTransportEventsResponse] }));
+export const VersionedHostJamPeerTransportOpenError = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostJamPeerTransportOpenError] }));
+export const VersionedHostJamPeerTransportOpenRequest = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostJamPeerTransportOpenRequest] }));
+export const VersionedHostJamPeerTransportOpenResponse = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostJamPeerTransportOpenResponse] }));
+export const VersionedHostJamPeerTransportRecvError = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostJamPeerTransportRecvError] }));
+export const VersionedHostJamPeerTransportRecvRequest = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostJamPeerTransportRecvRequest] }));
+export const VersionedHostJamPeerTransportRecvResponse = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostJamPeerTransportRecvResponse] }));
+export const VersionedHostJamPeerTransportResetError = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostJamPeerTransportResetError] }));
+export const VersionedHostJamPeerTransportResetRequest = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostJamPeerTransportResetRequest] }));
+export const VersionedHostJamPeerTransportResetResponse = S.lazy(() => S.indexedTaggedUnion({ V1: [0, S._void] }));
+export const VersionedHostJamPeerTransportSendError = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostJamPeerTransportSendError] }));
+export const VersionedHostJamPeerTransportSendRequest = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostJamPeerTransportSendRequest] }));
+export const VersionedHostJamPeerTransportSendResponse = S.lazy(() => S.indexedTaggedUnion({ V1: [0, S._void] }));
export const VersionedHostLocalStorageChangeItem = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostLocalStorageChangeItem] }));
export const VersionedHostLocalStorageClearError = S.lazy(() => S.indexedTaggedUnion({ V1: [0, V01HostLocalStorageReadError] }));
export const VersionedHostLocalStorageClearRequest = S.lazy(() => S.indexedTaggedUnion({ V1: [0, HostLocalStorageClearRequest] }));
@@ -283,6 +304,7 @@ export const HostWorkerOperationError = S.lazy(() => S.TaggedUnion({ TooManyOpen
export const ImageFit = S.lazy(() => S.Status("None", "Fill", "Cover", "Contain", "ScaleDown"));
export const ImageProps = S.lazy(() => S.Struct({ source: ImageSource, fit: S.Option(ImageFit) }));
export const ImageSource = S.lazy(() => S.TaggedUnion({ Bulletin: S.str, Archive: S.str }));
+export const JamPeerTransportEvent = S.lazy(() => S.TaggedUnion({ ConnClosed: S.Struct({ conn: S.u32 }), StreamFin: S.Struct({ stream: S.u32 }), Accepted: S.Struct({ conn: S.u32, stream: S.u32, kind: S.u8 }) }));
export const LegacyAccount = S.lazy(() => S.Struct({ publicKey: S.Hex(), name: S.Option(S.str) }));
export const LegacyAccountTxPayload = S.lazy(() => S.Struct({ signer: S.Hex(32), genesisHash: S.Hex(32), callData: S.Hex(), extensions: S.Vector(TxPayloadExtension), txExtVersion: S.u8 }));
export const Modifier = S.lazy(() => S.TaggedUnion({ Margin: Dimensions, Padding: Dimensions, Background: Background, Border: BorderStyle, Height: Size, Width: Size, MinWidth: Size, MinHeight: Size, FillWidth: S.bool, FillHeight: S.bool, Opacity: S.u8, BlendingMode: BlendingMode }));
@@ -344,7 +366,7 @@ export const VersionedRemoteChainTransactionBroadcastResponse = S.lazy(() => S.i
export const VersionedRemoteChainTransactionStopError = S.lazy(() => S.indexedTaggedUnion({ V1: [0, GenericError] }));
export const VersionedRemoteChainTransactionStopRequest = S.lazy(() => S.indexedTaggedUnion({ V1: [0, RemoteChainTransactionStopRequest] }));
export const VersionedRemoteChainTransactionStopResponse = S.lazy(() => S.indexedTaggedUnion({ V1: [0, S._void] }));
-export const RemotePermission = S.lazy(() => S.TaggedUnion({ Remote: S.Struct({ domains: S.Vector(S.str) }), WebRtc: S._void, ChainSubmit: S._void, PreimageSubmit: S._void, StatementSubmit: S._void }));
+export const RemotePermission = S.lazy(() => S.TaggedUnion({ Remote: S.Struct({ domains: S.Vector(S.str) }), WebRtc: S._void, ChainSubmit: S._void, PreimageSubmit: S._void, StatementSubmit: S._void, JamPeers: S.Struct({ genesis: S.Hex(32) }) }));
export const VersionedRemotePermissionError = S.lazy(() => S.indexedTaggedUnion({ V1: [0, GenericError] }));
export const VersionedRemotePermissionRequest = S.lazy(() => S.indexedTaggedUnion({ V1: [0, RemotePermissionRequest] }));
export const VersionedRemotePermissionResponse = S.lazy(() => S.indexedTaggedUnion({ V1: [0, RemotePermissionResponse] }));
@@ -458,6 +480,23 @@ export const HostGetUserIdError = S.lazy(() => S.TaggedUnion({ PermissionDenied:
export const HostGetUserIdResponse = S.lazy(() => S.Struct({ primaryUsername: S.str }));
export const HostHandshakeError = S.lazy(() => S.TaggedUnion({ Timeout: S._void, UnsupportedProtocolVersion: S._void, Unknown: GenericError }));
export const HostHandshakeRequest = S.lazy(() => S.Struct({ codecVersion: S.u8 }));
+export const HostJamPeerTransportCloseError = S.lazy(() => S.Status("Closed"));
+export const HostJamPeerTransportCloseRequest = S.lazy(() => S.Struct({ conn: S.u32 }));
+export const HostJamPeerTransportDialError = S.lazy(() => S.Status("NotGranted", "Refused", "Limit", "Unreachable"));
+export const HostJamPeerTransportDialRequest = S.lazy(() => S.Struct({ genesis: S.Hex(32), ip: S.Hex(16), port: S.u16, ed25519: S.Hex(32), p256: S.Option(S.Hex(33)) }));
+export const HostJamPeerTransportDialResponse = S.lazy(() => S.Struct({ conn: S.u32 }));
+export const HostJamPeerTransportEventsError = S.lazy(() => S.Status("NotGranted"));
+export const HostJamPeerTransportEventsResponse = S.lazy(() => S.Struct({ events: S.Vector(JamPeerTransportEvent) }));
+export const HostJamPeerTransportOpenError = S.lazy(() => S.Status("NotGranted", "Closed", "Limit"));
+export const HostJamPeerTransportOpenRequest = S.lazy(() => S.Struct({ conn: S.u32, kind: S.u8 }));
+export const HostJamPeerTransportOpenResponse = S.lazy(() => S.Struct({ stream: S.u32 }));
+export const HostJamPeerTransportRecvError = S.lazy(() => S.Status("Closed"));
+export const HostJamPeerTransportRecvRequest = S.lazy(() => S.Struct({ stream: S.u32, max: S.u32 }));
+export const HostJamPeerTransportRecvResponse = S.lazy(() => S.Struct({ message: S.Option(S.Hex()), fin: S.bool, reset: S.bool }));
+export const HostJamPeerTransportResetError = S.lazy(() => S.Status("Closed"));
+export const HostJamPeerTransportResetRequest = S.lazy(() => S.Struct({ stream: S.u32 }));
+export const HostJamPeerTransportSendError = S.lazy(() => S.Status("Closed", "TooLarge", "Limit"));
+export const HostJamPeerTransportSendRequest = S.lazy(() => S.Struct({ stream: S.u32, message: S.Hex(), fin: S.bool }));
export const HostLocalStorageChangeItem = S.lazy(() => S.Struct({ value: S.Option(S.Hex()) }));
export const HostLocalStorageClearRequest = S.lazy(() => S.Struct({ key: S.str }));
export const V01HostLocalStorageReadError = S.lazy(() => S.TaggedUnion({ Full: S._void, Unknown: S.Struct({ reason: S.str }) }));
diff --git a/vendor/truapi/dist/generated/wire-decode.js b/vendor/truapi/dist/generated/wire-decode.js
index 11830323..697aff96 100644
--- a/vendor/truapi/dist/generated/wire-decode.js
+++ b/vendor/truapi/dist/generated/wire-decode.js
@@ -420,4 +420,32 @@ export const WIRE_DECODE_TABLE = {
0: (payload) => T.VersionedHostProfilePresentOwnRequest.dec(payload),
1: (payload) => S.Result(T.VersionedHostProfilePresentOwnResponse, S.CallError(T.VersionedHostProfilePresentOwnError)).dec(payload),
},
+ [W.JAM_PEER_TRANSPORT_DIAL.trait * 256 + W.JAM_PEER_TRANSPORT_DIAL.method]: {
+ 0: (payload) => T.VersionedHostJamPeerTransportDialRequest.dec(payload),
+ 1: (payload) => S.Result(T.VersionedHostJamPeerTransportDialResponse, S.CallError(T.VersionedHostJamPeerTransportDialError)).dec(payload),
+ },
+ [W.JAM_PEER_TRANSPORT_OPEN.trait * 256 + W.JAM_PEER_TRANSPORT_OPEN.method]: {
+ 0: (payload) => T.VersionedHostJamPeerTransportOpenRequest.dec(payload),
+ 1: (payload) => S.Result(T.VersionedHostJamPeerTransportOpenResponse, S.CallError(T.VersionedHostJamPeerTransportOpenError)).dec(payload),
+ },
+ [W.JAM_PEER_TRANSPORT_SEND.trait * 256 + W.JAM_PEER_TRANSPORT_SEND.method]: {
+ 0: (payload) => T.VersionedHostJamPeerTransportSendRequest.dec(payload),
+ 1: (payload) => S.Result(T.VersionedHostJamPeerTransportSendResponse, S.CallError(T.VersionedHostJamPeerTransportSendError)).dec(payload),
+ },
+ [W.JAM_PEER_TRANSPORT_RECV.trait * 256 + W.JAM_PEER_TRANSPORT_RECV.method]: {
+ 0: (payload) => T.VersionedHostJamPeerTransportRecvRequest.dec(payload),
+ 1: (payload) => S.Result(T.VersionedHostJamPeerTransportRecvResponse, S.CallError(T.VersionedHostJamPeerTransportRecvError)).dec(payload),
+ },
+ [W.JAM_PEER_TRANSPORT_RESET.trait * 256 + W.JAM_PEER_TRANSPORT_RESET.method]: {
+ 0: (payload) => T.VersionedHostJamPeerTransportResetRequest.dec(payload),
+ 1: (payload) => S.Result(T.VersionedHostJamPeerTransportResetResponse, S.CallError(T.VersionedHostJamPeerTransportResetError)).dec(payload),
+ },
+ [W.JAM_PEER_TRANSPORT_CLOSE.trait * 256 + W.JAM_PEER_TRANSPORT_CLOSE.method]: {
+ 0: (payload) => T.VersionedHostJamPeerTransportCloseRequest.dec(payload),
+ 1: (payload) => S.Result(T.VersionedHostJamPeerTransportCloseResponse, S.CallError(T.VersionedHostJamPeerTransportCloseError)).dec(payload),
+ },
+ [W.JAM_PEER_TRANSPORT_EVENTS.trait * 256 + W.JAM_PEER_TRANSPORT_EVENTS.method]: {
+ 0: (payload) => T.VersionedHostJamPeerTransportEventsRequest.dec(payload),
+ 1: (payload) => S.Result(T.VersionedHostJamPeerTransportEventsResponse, S.CallError(T.VersionedHostJamPeerTransportEventsError)).dec(payload),
+ },
};
diff --git a/vendor/truapi/dist/generated/wire-table.d.ts b/vendor/truapi/dist/generated/wire-table.d.ts
index 53860a62..30e69f37 100644
--- a/vendor/truapi/dist/generated/wire-table.d.ts
+++ b/vendor/truapi/dist/generated/wire-table.d.ts
@@ -468,3 +468,38 @@ export declare const PROFILE_PRESENT_OWN: {
readonly method: 6;
readonly kind: "request";
};
+export declare const JAM_PEER_TRANSPORT_DIAL: {
+ readonly trait: 111;
+ readonly method: 0;
+ readonly kind: "request";
+};
+export declare const JAM_PEER_TRANSPORT_OPEN: {
+ readonly trait: 111;
+ readonly method: 1;
+ readonly kind: "request";
+};
+export declare const JAM_PEER_TRANSPORT_SEND: {
+ readonly trait: 111;
+ readonly method: 2;
+ readonly kind: "request";
+};
+export declare const JAM_PEER_TRANSPORT_RECV: {
+ readonly trait: 111;
+ readonly method: 3;
+ readonly kind: "request";
+};
+export declare const JAM_PEER_TRANSPORT_RESET: {
+ readonly trait: 111;
+ readonly method: 4;
+ readonly kind: "request";
+};
+export declare const JAM_PEER_TRANSPORT_CLOSE: {
+ readonly trait: 111;
+ readonly method: 5;
+ readonly kind: "request";
+};
+export declare const JAM_PEER_TRANSPORT_EVENTS: {
+ readonly trait: 111;
+ readonly method: 6;
+ readonly kind: "request";
+};
diff --git a/vendor/truapi/dist/generated/wire-table.js b/vendor/truapi/dist/generated/wire-table.js
index 9814a03e..509a64b5 100644
--- a/vendor/truapi/dist/generated/wire-table.js
+++ b/vendor/truapi/dist/generated/wire-table.js
@@ -479,3 +479,38 @@ export const PROFILE_PRESENT_OWN = {
method: 6,
kind: "request",
};
+export const JAM_PEER_TRANSPORT_DIAL = {
+ trait: 111,
+ method: 0,
+ kind: "request",
+};
+export const JAM_PEER_TRANSPORT_OPEN = {
+ trait: 111,
+ method: 1,
+ kind: "request",
+};
+export const JAM_PEER_TRANSPORT_SEND = {
+ trait: 111,
+ method: 2,
+ kind: "request",
+};
+export const JAM_PEER_TRANSPORT_RECV = {
+ trait: 111,
+ method: 3,
+ kind: "request",
+};
+export const JAM_PEER_TRANSPORT_RESET = {
+ trait: 111,
+ method: 4,
+ kind: "request",
+};
+export const JAM_PEER_TRANSPORT_CLOSE = {
+ trait: 111,
+ method: 5,
+ kind: "request",
+};
+export const JAM_PEER_TRANSPORT_EVENTS = {
+ trait: 111,
+ method: 6,
+ kind: "request",
+};
diff --git a/vendor/truapi/dist/jam-peer-transport-cert.d.ts b/vendor/truapi/dist/jam-peer-transport-cert.d.ts
new file mode 100644
index 00000000..c207bcc8
--- /dev/null
+++ b/vendor/truapi/dist/jam-peer-transport-cert.d.ts
@@ -0,0 +1,61 @@
+/**
+ * Deterministic WebTransport certificate hashes for a PolkaJAM peer.
+ *
+ * PolkaJAM (`crates/node/src/net/cert.rs`, `dd9af78`) serves an unsigned X.509
+ * certificate for its P-256 peer key: issuer and subject `CN=jam`, one dNSName
+ * SAN equal to the peer-id text, Ed25519 signature algorithm with an all-zero
+ * 64-byte signature, and a validity window derived from a fixed 10-day period
+ * padded by one day on both sides. A client that knows the peer's compressed
+ * P-256 key can therefore compute the certificate hashes offline and pass them
+ * as `serverCertificateHashes`. These bytes mirror PolkaJAM's
+ * `crates/node/src/net/cert.rs` generated with rcgen 0.14.8.
+ *
+ * Stock PolkaJAM gives every certificate serial 0. NSS (Firefox) rejects a
+ * second certificate with an issuer and serial it has already seen
+ * (`SEC_ERROR_REUSED_ISSUER_AND_SERIAL`), so such a browser reaches only one
+ * validator. Nodes built with jam-explore's
+ * `polkajam-webtransport-serial.patch` use {@link webTransportSerial} instead;
+ * clients pin both variants so they reach stock and patched nodes alike.
+ */
+export declare const UNPADDED_VALIDITY_PERIOD_SECS: number;
+export declare const VALIDITY_PERIOD_PADDING_SECS: number;
+/** PolkaJAM peer-id text: prefix letter then 32 bytes, base-32 LSB-first. */
+export declare function peerIdText(prefix: string, bytes: Uint8Array): string;
+/** Parse `e…`, `o…` or `v…` text into (prefix, 32 bytes). */
+export declare function parsePeerIdText(text: string): {
+ prefix: string;
+ bytes: Uint8Array;
+};
+/** `o…`/`v…` text to a compressed SEC1 P-256 point (0x03 odd y / 0x02 even y). */
+export declare function p256IdToCompressed(text: string): Uint8Array;
+/** Ed25519 `e…` text to the 32-byte public key. */
+export declare function ed25519IdToKey(text: string): Uint8Array;
+/** Uncompressed SEC1 (0x04 ‖ x ‖ y) for a compressed P-256 point; p ≡ 3 mod 4. */
+export declare function decompressP256(compressed: Uint8Array): Uint8Array;
+/** Fixed 10-day period index for a unix time; the server switches at boundaries. */
+export declare function validityPeriodAt(unixSecs: number): number;
+/** `[notBefore, notAfter]` unix seconds of a period (padded by one day). */
+export declare function validityBounds(period: number): [number, number];
+/**
+ * Which serial a certificate carries: `distinct` ({@link webTransportSerial},
+ * patched nodes) or `legacy` (0, stock PolkaJAM).
+ */
+export type CertificateSerial = "distinct" | "legacy";
+/**
+ * Serial of the patched PolkaJAM certificate for `compressed` during
+ * `period`: the first 8 bytes of SHA-256(`compressed` ‖ period as a
+ * big-endian u64), read big-endian with the top bit cleared, or 1 if that
+ * is 0.
+ */
+export declare function webTransportSerial(compressed: Uint8Array, period: number): bigint;
+/** DER certificate PolkaJAM presents for `compressed` during `period`. */
+export declare function webTransportCertificateDer(compressed: Uint8Array, period: number, serial: CertificateSerial): Uint8Array;
+/** SHA-256 of {@link webTransportCertificateDer}. */
+export declare function webTransportCertificateHash(compressed: Uint8Array, period: number, serial: CertificateSerial): Uint8Array;
+/**
+ * Hashes to pass as `serverCertificateHashes` at `unixSecs`: both serial
+ * variants for the current period plus both neighbours, so a clock skew or a
+ * boundary crossing during the handshake still matches whichever certificate
+ * a stock or patched server picked.
+ */
+export declare function webTransportCertificateHashes(compressed: Uint8Array, unixSecs: number): Uint8Array[];
diff --git a/vendor/truapi/dist/jam-peer-transport-cert.js b/vendor/truapi/dist/jam-peer-transport-cert.js
new file mode 100644
index 00000000..1bd454e3
--- /dev/null
+++ b/vendor/truapi/dist/jam-peer-transport-cert.js
@@ -0,0 +1,213 @@
+import { sha256 } from "@noble/hashes/sha2.js";
+/**
+ * Deterministic WebTransport certificate hashes for a PolkaJAM peer.
+ *
+ * PolkaJAM (`crates/node/src/net/cert.rs`, `dd9af78`) serves an unsigned X.509
+ * certificate for its P-256 peer key: issuer and subject `CN=jam`, one dNSName
+ * SAN equal to the peer-id text, Ed25519 signature algorithm with an all-zero
+ * 64-byte signature, and a validity window derived from a fixed 10-day period
+ * padded by one day on both sides. A client that knows the peer's compressed
+ * P-256 key can therefore compute the certificate hashes offline and pass them
+ * as `serverCertificateHashes`. These bytes mirror PolkaJAM's
+ * `crates/node/src/net/cert.rs` generated with rcgen 0.14.8.
+ *
+ * Stock PolkaJAM gives every certificate serial 0. NSS (Firefox) rejects a
+ * second certificate with an issuer and serial it has already seen
+ * (`SEC_ERROR_REUSED_ISSUER_AND_SERIAL`), so such a browser reaches only one
+ * validator. Nodes built with jam-explore's
+ * `polkajam-webtransport-serial.patch` use {@link webTransportSerial} instead;
+ * clients pin both variants so they reach stock and patched nodes alike.
+ */
+export const UNPADDED_VALIDITY_PERIOD_SECS = 10 * 24 * 3600;
+export const VALIDITY_PERIOD_PADDING_SECS = 24 * 3600;
+const BITS_TO_CHAR = "abcdefghijklmnopqrstuvwxyz234567";
+const P = (1n << 256n) - (1n << 224n) + (1n << 192n) + (1n << 96n) - 1n;
+const B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn;
+const OID_ED25519 = [0x06, 0x03, 0x2b, 0x65, 0x70];
+const OID_EC_PUBLIC_KEY = [0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01];
+const OID_PRIME256V1 = [0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07];
+const OID_SUBJECT_ALT_NAME = [0x06, 0x03, 0x55, 0x1d, 0x11];
+const ascii = new TextEncoder();
+/** PolkaJAM peer-id text: prefix letter then 32 bytes, base-32 LSB-first. */
+export function peerIdText(prefix, bytes) {
+ if (bytes.length !== 32)
+ throw new Error("peer id needs 32 bytes");
+ let text = prefix;
+ for (let i = 0; i < 256; i += 5) {
+ const low = bytes[i >> 3];
+ const high = bytes[(i >> 3) + 1] ?? 0;
+ text += BITS_TO_CHAR[((low | (high << 8)) >> (i % 8)) & 0x1f];
+ }
+ return text;
+}
+/** Parse `e…`, `o…` or `v…` text into (prefix, 32 bytes). */
+export function parsePeerIdText(text) {
+ if (text.length !== 53)
+ throw new Error(`peer id text must be 53 characters, got ${text.length}`);
+ const bytes = new Uint8Array(32);
+ let acc = 0;
+ let n = 0;
+ let i = 0;
+ for (const char of text.slice(1)) {
+ const bits = BITS_TO_CHAR.indexOf(char);
+ if (bits < 0)
+ throw new Error(`invalid peer id character ${JSON.stringify(char)}`);
+ acc |= bits << n;
+ n += 5;
+ if (n >= 8) {
+ bytes[i++] = acc & 0xff;
+ acc >>= 8;
+ n -= 8;
+ }
+ }
+ if (acc !== 0)
+ throw new Error("peer id has non-zero trailing bits");
+ return { prefix: text[0], bytes };
+}
+/** `o…`/`v…` text to a compressed SEC1 P-256 point (0x03 odd y / 0x02 even y). */
+export function p256IdToCompressed(text) {
+ const { prefix, bytes } = parsePeerIdText(text);
+ if (prefix !== "o" && prefix !== "v")
+ throw new Error("P-256 peer ids begin with 'o' or 'v'");
+ const out = new Uint8Array(33);
+ out[0] = prefix === "o" ? 3 : 2;
+ out.set(bytes, 1);
+ return out;
+}
+/** Ed25519 `e…` text to the 32-byte public key. */
+export function ed25519IdToKey(text) {
+ const { prefix, bytes } = parsePeerIdText(text);
+ if (prefix !== "e")
+ throw new Error("Ed25519 peer ids begin with 'e'");
+ return bytes;
+}
+function bigintFromBytes(bytes) {
+ let v = 0n;
+ for (const b of bytes)
+ v = (v << 8n) | BigInt(b);
+ return v;
+}
+function bytesFromBigint(v, length) {
+ const out = new Uint8Array(length);
+ for (let i = length - 1; i >= 0; i--) {
+ out[i] = Number(v & 0xffn);
+ v >>= 8n;
+ }
+ return out;
+}
+function modPow(base, exp, mod) {
+ let result = 1n;
+ base %= mod;
+ while (exp > 0n) {
+ if (exp & 1n)
+ result = (result * base) % mod;
+ base = (base * base) % mod;
+ exp >>= 1n;
+ }
+ return result;
+}
+/** Uncompressed SEC1 (0x04 ‖ x ‖ y) for a compressed P-256 point; p ≡ 3 mod 4. */
+export function decompressP256(compressed) {
+ if (compressed.length !== 33 || (compressed[0] !== 2 && compressed[0] !== 3)) {
+ throw new Error("expected a 33-byte compressed P-256 point");
+ }
+ const x = bigintFromBytes(compressed.subarray(1));
+ if (x >= P)
+ throw new Error("P-256 x coordinate out of range");
+ const rhs = (((x * x) % P) * x - 3n * x + B) % P;
+ const alpha = (rhs + P) % P;
+ let y = modPow(alpha, (P + 1n) >> 2n, P);
+ if ((y * y) % P !== alpha)
+ throw new Error("P-256 x coordinate is not on the curve");
+ if ((y & 1n) !== BigInt(compressed[0] & 1))
+ y = P - y;
+ const out = new Uint8Array(65);
+ out[0] = 4;
+ out.set(bytesFromBigint(x, 32), 1);
+ out.set(bytesFromBigint(y, 32), 33);
+ return out;
+}
+function der(tag, ...parts) {
+ const body = parts.flatMap((part) => Array.from(part));
+ const len = body.length;
+ const header = len < 0x80
+ ? [tag, len]
+ : len < 0x100
+ ? [tag, 0x81, len]
+ : [tag, 0x82, len >> 8, len & 0xff];
+ return header.concat(body);
+}
+/** Minimal DER encoding of a non-negative INTEGER. */
+function derUnsigned(v) {
+ const bytes = [];
+ for (; v > 0n; v >>= 8n)
+ bytes.unshift(Number(v & 0xffn));
+ if (bytes.length === 0 || bytes[0] >= 0x80)
+ bytes.unshift(0);
+ return der(0x02, bytes);
+}
+function utcTime(unixSecs) {
+ const date = new Date(unixSecs * 1000);
+ const year = date.getUTCFullYear();
+ if (year < 1950 || year >= 2050) {
+ throw new Error("validity outside the UTCTime range PolkaJAM certificates use");
+ }
+ const two = (n) => String(n).padStart(2, "0");
+ const text = `${two(year % 100)}${two(date.getUTCMonth() + 1)}${two(date.getUTCDate())}${two(date.getUTCHours())}${two(date.getUTCMinutes())}${two(date.getUTCSeconds())}Z`;
+ return der(0x17, ascii.encode(text));
+}
+const JAM_DN = der(0x30, der(0x31, der(0x30, [0x06, 0x03, 0x55, 0x04, 0x03], der(0x0c, ascii.encode("jam")))));
+const ED25519_ALG = der(0x30, OID_ED25519);
+/** Fixed 10-day period index for a unix time; the server switches at boundaries. */
+export function validityPeriodAt(unixSecs) {
+ return Math.floor(unixSecs / UNPADDED_VALIDITY_PERIOD_SECS);
+}
+/** `[notBefore, notAfter]` unix seconds of a period (padded by one day). */
+export function validityBounds(period) {
+ return [
+ Math.max(period * UNPADDED_VALIDITY_PERIOD_SECS - VALIDITY_PERIOD_PADDING_SECS, 0),
+ (period + 1) * UNPADDED_VALIDITY_PERIOD_SECS + VALIDITY_PERIOD_PADDING_SECS,
+ ];
+}
+/**
+ * Serial of the patched PolkaJAM certificate for `compressed` during
+ * `period`: the first 8 bytes of SHA-256(`compressed` ‖ period as a
+ * big-endian u64), read big-endian with the top bit cleared, or 1 if that
+ * is 0.
+ */
+export function webTransportSerial(compressed, period) {
+ const input = new Uint8Array(compressed.length + 8);
+ input.set(compressed);
+ input.set(bytesFromBigint(BigInt(period), 8), compressed.length);
+ const serial = bigintFromBytes(sha256(input).subarray(0, 8)) & ((1n << 63n) - 1n);
+ return serial === 0n ? 1n : serial;
+}
+/** DER certificate PolkaJAM presents for `compressed` during `period`. */
+export function webTransportCertificateDer(compressed, period, serial) {
+ const point = decompressP256(compressed);
+ const altName = peerIdText(compressed[0] === 3 ? "o" : "v", compressed.subarray(1));
+ const [notBefore, notAfter] = validityBounds(period);
+ const spki = der(0x30, der(0x30, OID_EC_PUBLIC_KEY, OID_PRIME256V1), der(0x03, [0x00], point));
+ const san = der(0x30, der(0x30, OID_SUBJECT_ALT_NAME, der(0x04, der(0x30, der(0x82, ascii.encode(altName))))));
+ const tbs = der(0x30, der(0xa0, der(0x02, [0x02])), derUnsigned(serial === "distinct" ? webTransportSerial(compressed, period) : 0n), ED25519_ALG, JAM_DN, der(0x30, utcTime(notBefore), utcTime(notAfter)), JAM_DN, spki, der(0xa3, san));
+ return Uint8Array.from(der(0x30, tbs, ED25519_ALG, der(0x03, [0x00], new Uint8Array(64))));
+}
+/** SHA-256 of {@link webTransportCertificateDer}. */
+export function webTransportCertificateHash(compressed, period, serial) {
+ return sha256(webTransportCertificateDer(compressed, period, serial));
+}
+/**
+ * Hashes to pass as `serverCertificateHashes` at `unixSecs`: both serial
+ * variants for the current period plus both neighbours, so a clock skew or a
+ * boundary crossing during the handshake still matches whichever certificate
+ * a stock or patched server picked.
+ */
+export function webTransportCertificateHashes(compressed, unixSecs) {
+ const period = validityPeriodAt(unixSecs);
+ return [period - 1, period, period + 1]
+ .filter((p) => p >= 0)
+ .flatMap((p) => [
+ webTransportCertificateHash(compressed, p, "distinct"),
+ webTransportCertificateHash(compressed, p, "legacy"),
+ ]);
+}
diff --git a/vendor/truapi/dist/jam-peer-transport.d.ts b/vendor/truapi/dist/jam-peer-transport.d.ts
new file mode 100644
index 00000000..5eb2f61e
--- /dev/null
+++ b/vendor/truapi/dist/jam-peer-transport.d.ts
@@ -0,0 +1,64 @@
+/** Caps mirrored from `truapi::v01::jam_peer_transport`. */
+export declare const JAM_PEER_TRANSPORT_MAX_CONNECTIONS = 8;
+export declare const JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION = 16;
+export declare const JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES: number;
+export declare const JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION: number;
+/** Largest request frame: a `send` of a maximal message plus SCALE and wire overhead. */
+export declare const JAM_PEER_TRANSPORT_MAX_FRAME_BYTES: number;
+/**
+ * Bound on one `dial`, from its arrival to its reply, the permission decision
+ * included. A guest that waits at least this long for a dial reply never
+ * misses one, and anything a dial would open after it is closed instead.
+ */
+export declare const JAM_PEER_TRANSPORT_DIAL_TIMEOUT_MS = 10000;
+/** Minimal WebTransport surface the session needs; lets tests inject a fake. */
+export interface WebTransportLike {
+ readonly ready: Promise;
+ readonly closed: Promise;
+ readonly incomingBidirectionalStreams: ReadableStream;
+ createBidirectionalStream(): Promise;
+ close(): void;
+}
+export interface WebTransportBidirectionalStreamLike {
+ readonly readable: ReadableStream;
+ readonly writable: WritableStream;
+}
+export interface JamPeerTransportOptions {
+ /**
+ * Decide whether this execution may dial peers of `genesis`, a `0x`-prefixed
+ * lower-case 32-byte genesis header hash: the host's check of the
+ * `RemotePermission::JamPeers` runtime permission. The session asks at most
+ * once per genesis and concurrent dials share the pending answer; `false` or
+ * a rejection answers `NotGranted` for the rest of the session.
+ */
+ authorize(genesis: string): Promise;
+ /** Host transport injection; defaults to the browser `WebTransport` constructor. */
+ connect?: (url: string, certificateHashes: Uint8Array[]) => WebTransportLike;
+ /** Unix seconds used to select certificate validity periods; defaults to the wall clock. */
+ now?: () => number;
+ /** Dial deadline in milliseconds; defaults to {@link JAM_PEER_TRANSPORT_DIAL_TIMEOUT_MS}. */
+ dialTimeoutMs?: number;
+}
+/** Execution-local peer endpoint. It provides no account or signing authority. */
+export interface JamPeerTransportSession {
+ /** Handle one request frame; CANCEL frames return zero bytes. */
+ handleFrame(frame: Uint8Array): Promise;
+ /** Close every connection on stop or replacement and refuse further requests. */
+ close(): void;
+}
+/** Trait id of a request frame, or `undefined` when it does not decode. */
+export declare function frameTraitId(frame: Uint8Array): number | undefined;
+/** `https://` authority for a 16-byte IPv6 or v4-mapped address. */
+export declare function peerUrl(ip: Uint8Array, port: number): string;
+/**
+ * Create the browser JamPeerTransport endpoint for one execution. Every `dial`
+ * is authorized for its genesis through `options.authorize` before anything
+ * connects; the other methods act only on connections an authorized dial
+ * opened. A dial answers within its deadline, prompt included: one still
+ * waiting then answers `Unreachable`, a CANCEL naming it answers `Cancelled`,
+ * and in both cases whatever it opened is closed without holding a slot. The
+ * permission decision is remembered either way, so a retry does not ask
+ * again. The host must fence late replies against execution stop or
+ * replacement.
+ */
+export declare function createJamPeerTransportSession(options: JamPeerTransportOptions): JamPeerTransportSession;
diff --git a/vendor/truapi/dist/jam-peer-transport.js b/vendor/truapi/dist/jam-peer-transport.js
new file mode 100644
index 00000000..d45abf30
--- /dev/null
+++ b/vendor/truapi/dist/jam-peer-transport.js
@@ -0,0 +1,501 @@
+import * as S from "./scale.js";
+import * as T from "./generated/types.js";
+import { TRUAPI_CODEC_VERSION } from "./generated/client.js";
+import { JAM_PEER_TRANSPORT_CLOSE, JAM_PEER_TRANSPORT_DIAL, JAM_PEER_TRANSPORT_EVENTS, JAM_PEER_TRANSPORT_OPEN, JAM_PEER_TRANSPORT_RECV, JAM_PEER_TRANSPORT_RESET, JAM_PEER_TRANSPORT_SEND, SYSTEM_HANDSHAKE, } from "./generated/wire-table.js";
+import { decodeWireMessage, encodeWireMessage, MESSAGE_TYPE_CANCEL, MESSAGE_TYPE_REQUEST, MESSAGE_TYPE_RESPONSE, } from "./transport.js";
+import { webTransportCertificateHashes } from "./jam-peer-transport-cert.js";
+/** Caps mirrored from `truapi::v01::jam_peer_transport`. */
+export const JAM_PEER_TRANSPORT_MAX_CONNECTIONS = 8;
+export const JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION = 16;
+export const JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES = 1 << 20;
+export const JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION = 4 << 20;
+/** Largest request frame: a `send` of a maximal message plus SCALE and wire overhead. */
+export const JAM_PEER_TRANSPORT_MAX_FRAME_BYTES = JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES + 4096;
+const MAX_PENDING_EVENTS = 1024;
+/**
+ * Bound on one `dial`, from its arrival to its reply, the permission decision
+ * included. A guest that waits at least this long for a dial reply never
+ * misses one, and anything a dial would open after it is closed instead.
+ */
+export const JAM_PEER_TRANSPORT_DIAL_TIMEOUT_MS = 10_000;
+const textEncoder = new TextEncoder();
+const handshakeResult = S.Result(T.VersionedHostHandshakeResponse, S.CallError(T.VersionedHostHandshakeError));
+const frameworkResult = S.Result(S._void, S.CallError(S._void));
+const dialResult = S.Result(T.VersionedHostJamPeerTransportDialResponse, S.CallError(T.VersionedHostJamPeerTransportDialError));
+const openResult = S.Result(T.VersionedHostJamPeerTransportOpenResponse, S.CallError(T.VersionedHostJamPeerTransportOpenError));
+const sendResult = S.Result(T.VersionedHostJamPeerTransportSendResponse, S.CallError(T.VersionedHostJamPeerTransportSendError));
+const recvResult = S.Result(T.VersionedHostJamPeerTransportRecvResponse, S.CallError(T.VersionedHostJamPeerTransportRecvError));
+const resetResult = S.Result(T.VersionedHostJamPeerTransportResetResponse, S.CallError(T.VersionedHostJamPeerTransportResetError));
+const closeResult = S.Result(T.VersionedHostJamPeerTransportCloseResponse, S.CallError(T.VersionedHostJamPeerTransportCloseError));
+const eventsResult = S.Result(T.VersionedHostJamPeerTransportEventsResponse, S.CallError(T.VersionedHostJamPeerTransportEventsError));
+const cancelledReply = frameworkResult.enc({ success: false, value: { tag: "Cancelled" } });
+/** Trait id of a request frame, or `undefined` when it does not decode. */
+export function frameTraitId(frame) {
+ const decoded = decodeWireMessage(frame);
+ return decoded.isOk() ? decoded.value.payload.traitId : undefined;
+}
+function exact(codec, bytes) {
+ const value = codec.dec(bytes);
+ const canonical = codec.enc(value);
+ if (canonical.length !== bytes.length || canonical.some((byte, index) => byte !== bytes[index])) {
+ throw new Error("Noncanonical or trailing SCALE bytes");
+ }
+ return value;
+}
+function decodeFrame(bytes) {
+ if (!(bytes instanceof Uint8Array) || bytes.length > JAM_PEER_TRANSPORT_MAX_FRAME_BYTES) {
+ throw new Error("Invalid or oversized peer-transport frame");
+ }
+ const decoded = decodeWireMessage(bytes);
+ if (decoded.isErr())
+ throw decoded.error;
+ const message = decoded.value;
+ if (textEncoder.encode(message.requestId).length > 64)
+ throw new Error("Oversized request id");
+ const encoded = encodeWireMessage(message);
+ if (encoded.isErr())
+ throw encoded.error;
+ if (encoded.value.length !== bytes.length || encoded.value.some((byte, index) => byte !== bytes[index])) {
+ throw new Error("Noncanonical request frame");
+ }
+ return message;
+}
+function hasIds(message, ids) {
+ return message.payload.traitId === ids.trait && message.payload.methodId === ids.method;
+}
+function reply(request, value) {
+ const encoded = encodeWireMessage({
+ requestId: request.requestId,
+ payload: { ...request.payload, messageType: MESSAGE_TYPE_RESPONSE, value },
+ });
+ if (encoded.isErr())
+ throw encoded.error;
+ return encoded.value;
+}
+function ok(codec, value) {
+ return codec.enc({ success: true, value });
+}
+function domain(codec, error) {
+ return codec.enc({ success: false, value: { tag: "Domain", value: { tag: "V1", value: error } } });
+}
+/** `https://` authority for a 16-byte IPv6 or v4-mapped address. */
+export function peerUrl(ip, port) {
+ if (ip.length !== 16)
+ throw new Error("peer ip must be 16 bytes");
+ const v4Mapped = ip.subarray(0, 10).every((byte) => byte === 0) && ip[10] === 0xff && ip[11] === 0xff;
+ if (v4Mapped)
+ return `https://${ip[12]}.${ip[13]}.${ip[14]}.${ip[15]}:${port}`;
+ const groups = [];
+ for (let i = 0; i < 16; i += 2)
+ groups.push(((ip[i] << 8) | ip[i + 1]).toString(16));
+ return `https://[${groups.join(":")}]:${port}`;
+}
+/**
+ * Create the browser JamPeerTransport endpoint for one execution. Every `dial`
+ * is authorized for its genesis through `options.authorize` before anything
+ * connects; the other methods act only on connections an authorized dial
+ * opened. A dial answers within its deadline, prompt included: one still
+ * waiting then answers `Unreachable`, a CANCEL naming it answers `Cancelled`,
+ * and in both cases whatever it opened is closed without holding a slot. The
+ * permission decision is remembered either way, so a retry does not ask
+ * again. The host must fence late replies against execution stop or
+ * replacement.
+ */
+export function createJamPeerTransportSession(options) {
+ const decisions = new Map();
+ const authorized = (genesis) => {
+ let decision = decisions.get(genesis);
+ if (decision === undefined) {
+ decision = (async () => {
+ try {
+ return (await options.authorize(genesis)) === true;
+ }
+ catch {
+ return false;
+ }
+ })();
+ decisions.set(genesis, decision);
+ }
+ return decision;
+ };
+ const connect = options.connect ??
+ ((url, hashes) => new WebTransport(url, {
+ serverCertificateHashes: hashes.map((value) => ({ algorithm: "sha-256", value: value })),
+ }));
+ const now = options.now ?? (() => Math.floor(Date.now() / 1000));
+ const dialTimeoutMs = options.dialTimeoutMs ?? JAM_PEER_TRANSPORT_DIAL_TIMEOUT_MS;
+ /** In-flight dials by request id; CANCEL or `close` withdraws one with its reply. */
+ const pendingDials = new Map();
+ let closed = false;
+ let negotiated = false;
+ let nextConn = 1;
+ let nextStream = 1;
+ const connections = new Map();
+ const streams = new Map();
+ const events = [];
+ const pushEvent = (event) => {
+ if (events.length < MAX_PENDING_EVENTS)
+ events.push(event);
+ };
+ const dropStream = (stream, abort) => {
+ streams.delete(stream.id);
+ stream.conn.streams.delete(stream.id);
+ if (abort) {
+ void stream.writer.abort().catch(() => undefined);
+ void stream.reader.cancel().catch(() => undefined);
+ }
+ };
+ const dropConnection = (conn) => {
+ if (conn.closed)
+ return;
+ conn.closed = true;
+ connections.delete(conn.id);
+ for (const stream of [...conn.streams.values()])
+ dropStream(stream, true);
+ try {
+ conn.transport.close();
+ }
+ catch {
+ // Already closed by the peer.
+ }
+ pushEvent({ tag: "ConnClosed", value: { conn: conn.id } });
+ };
+ /** Parse complete `u32`-LE framed messages out of `stream.rx`. */
+ const unframe = (stream) => {
+ while (stream.rx.length >= 4) {
+ const view = new DataView(stream.rx.buffer, stream.rx.byteOffset, stream.rx.byteLength);
+ const length = view.getUint32(0, true);
+ if (length > JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES) {
+ stream.reset = true;
+ void stream.writer.abort().catch(() => undefined);
+ void stream.reader.cancel().catch(() => undefined);
+ stream.rx = new Uint8Array();
+ return;
+ }
+ if (stream.rx.length < 4 + length)
+ return;
+ stream.messages.push(stream.rx.slice(4, 4 + length));
+ stream.rx = stream.rx.slice(4 + length);
+ }
+ };
+ const pump = async (stream, initial) => {
+ stream.rx = initial;
+ unframe(stream);
+ try {
+ while (!stream.reset) {
+ const { value, done } = await stream.reader.read();
+ if (done)
+ break;
+ const next = new Uint8Array(stream.rx.length + value.length);
+ next.set(stream.rx);
+ next.set(value, stream.rx.length);
+ stream.rx = next;
+ unframe(stream);
+ }
+ if (!stream.reset) {
+ stream.fin = true;
+ if (stream.rx.length !== 0)
+ stream.reset = true;
+ if (streams.has(stream.id))
+ pushEvent({ tag: "StreamFin", value: { stream: stream.id } });
+ }
+ }
+ catch {
+ stream.reset = true;
+ }
+ };
+ const register = (conn, bidi, initial) => {
+ const stream = {
+ id: nextStream++,
+ conn,
+ writer: bidi.writable.getWriter(),
+ reader: bidi.readable.getReader(),
+ rx: new Uint8Array(),
+ messages: [],
+ fin: false,
+ reset: false,
+ rxConsumed: false,
+ txClosed: false,
+ txPending: 0,
+ };
+ streams.set(stream.id, stream);
+ conn.streams.set(stream.id, stream);
+ void pump(stream, initial);
+ return stream;
+ };
+ const acceptLoop = async (conn) => {
+ const incoming = conn.transport.incomingBidirectionalStreams.getReader();
+ try {
+ while (!conn.closed) {
+ const { value: bidi, done } = await incoming.read();
+ if (done || conn.closed)
+ break;
+ if (conn.streams.size >= JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION) {
+ void bidi.writable.abort().catch(() => undefined);
+ void bidi.readable.cancel().catch(() => undefined);
+ continue;
+ }
+ // The peer's first byte is the stream kind; anything after it is message data.
+ const reader = bidi.readable.getReader();
+ const first = await reader.read();
+ reader.releaseLock();
+ if (first.done || first.value.length === 0 || conn.closed) {
+ void bidi.writable.abort().catch(() => undefined);
+ continue;
+ }
+ const stream = register(conn, bidi, first.value.subarray(1));
+ pushEvent({ tag: "Accepted", value: { conn: conn.id, stream: stream.id, kind: first.value[0] } });
+ }
+ }
+ catch {
+ // The connection is closing; `closed` handling reports it.
+ }
+ };
+ /**
+ * One dial. `withdrawn` settles with the reply when the guest cancels or the
+ * deadline passes; the guest then no longer waits for what this dial opens,
+ * so nothing it opens outlives it or holds a connection slot.
+ */
+ const dial = async (request, withdrawn) => {
+ const granted = await Promise.race([authorized(request.genesis), withdrawn]);
+ if (granted instanceof Uint8Array)
+ return granted;
+ if (!granted)
+ return domain(dialResult, "NotGranted");
+ if (closed)
+ return frameworkResult.enc({ success: false, value: { tag: "Denied" } });
+ if (connections.size >= JAM_PEER_TRANSPORT_MAX_CONNECTIONS)
+ return domain(dialResult, "Limit");
+ // Browsers only expose WebTransport; JAMNP-S QUIC needs the P-256 identity.
+ if (request.p256 === undefined)
+ return domain(dialResult, "Unreachable");
+ const p256 = S.hexToBytes(request.p256);
+ if (p256.length !== 33 || (p256[0] !== 2 && p256[0] !== 3))
+ return domain(dialResult, "Refused");
+ let transport;
+ try {
+ transport = connect(peerUrl(S.hexToBytes(request.ip), request.port), webTransportCertificateHashes(p256, now()));
+ }
+ catch {
+ return domain(dialResult, "Unreachable");
+ }
+ const conn = { id: nextConn++, transport, streams: new Map(), closed: false };
+ connections.set(conn.id, conn);
+ const failure = await Promise.race([
+ transport.ready.then(() => undefined, () => domain(dialResult, "Refused")),
+ withdrawn,
+ ]);
+ if (failure !== undefined || closed) {
+ // The guest never learns this connection id, so it frees its slot
+ // without a `ConnClosed` event.
+ connections.delete(conn.id);
+ conn.closed = true;
+ try {
+ transport.close();
+ }
+ catch {
+ // Never opened.
+ }
+ return failure ?? frameworkResult.enc({ success: false, value: { tag: "Denied" } });
+ }
+ void transport.closed.then(() => dropConnection(conn), () => dropConnection(conn));
+ void acceptLoop(conn);
+ return ok(dialResult, { tag: "V1", value: { conn: conn.id } });
+ };
+ /** Run one dial frame: register it for CANCEL and arm its deadline. */
+ const dialFrame = async (requestId, request) => {
+ // Executor form: this package's lib target predates Promise.withResolvers.
+ let withdraw;
+ const withdrawn = new Promise((resolve) => {
+ withdraw = resolve;
+ });
+ pendingDials.set(requestId, withdraw);
+ const timer = setTimeout(() => withdraw(domain(dialResult, "Unreachable")), dialTimeoutMs);
+ try {
+ return await dial(request, withdrawn);
+ }
+ finally {
+ clearTimeout(timer);
+ pendingDials.delete(requestId);
+ }
+ };
+ const open = async (request) => {
+ const conn = connections.get(request.conn);
+ if (conn === undefined || conn.closed)
+ return domain(openResult, "Closed");
+ if (conn.streams.size >= JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION)
+ return domain(openResult, "Limit");
+ try {
+ const bidi = await conn.transport.createBidirectionalStream();
+ const stream = register(conn, bidi, new Uint8Array());
+ await stream.writer.write(new Uint8Array([request.kind]));
+ return ok(openResult, { tag: "V1", value: { stream: stream.id } });
+ }
+ catch {
+ return domain(openResult, "Closed");
+ }
+ };
+ const send = async (request) => {
+ const stream = streams.get(request.stream);
+ if (stream === undefined || stream.txClosed || stream.reset || stream.conn.closed)
+ return domain(sendResult, "Closed");
+ const message = S.hexToBytes(request.message);
+ if (message.length > JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES)
+ return domain(sendResult, "TooLarge");
+ let pending = 0;
+ for (const other of stream.conn.streams.values())
+ pending += other.txPending;
+ if (pending + message.length + 4 > JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION)
+ return domain(sendResult, "Limit");
+ const frame = new Uint8Array(4 + message.length);
+ new DataView(frame.buffer).setUint32(0, message.length, true);
+ frame.set(message, 4);
+ stream.txPending += frame.length;
+ try {
+ await stream.writer.write(frame);
+ if (request.fin) {
+ stream.txClosed = true;
+ await stream.writer.close();
+ }
+ return ok(sendResult, { tag: "V1" });
+ }
+ catch {
+ stream.txClosed = true;
+ return domain(sendResult, "Closed");
+ }
+ finally {
+ stream.txPending -= frame.length;
+ }
+ };
+ const recv = (request) => {
+ const stream = streams.get(request.stream);
+ if (stream === undefined || stream.rxConsumed)
+ return domain(recvResult, "Closed");
+ const next = stream.messages[0];
+ if (next !== undefined && next.length > request.max) {
+ // The guest cannot take this message; treat it as a protocol violation.
+ stream.messages.length = 0;
+ stream.reset = true;
+ void stream.writer.abort().catch(() => undefined);
+ void stream.reader.cancel().catch(() => undefined);
+ }
+ let message;
+ if (!stream.reset && next !== undefined) {
+ stream.messages.shift();
+ message = S.bytesToHex(next);
+ }
+ const drained = stream.messages.length === 0;
+ const fin = stream.fin && drained;
+ const reset = stream.reset;
+ if (message === undefined && (fin || reset)) {
+ stream.rxConsumed = true;
+ if (stream.txClosed || reset)
+ dropStream(stream, reset);
+ }
+ return ok(recvResult, { tag: "V1", value: { message, fin, reset } });
+ };
+ const reset = (request) => {
+ const stream = streams.get(request.stream);
+ if (stream === undefined)
+ return domain(resetResult, "Closed");
+ dropStream(stream, true);
+ return ok(resetResult, { tag: "V1" });
+ };
+ const close = (request) => {
+ const conn = connections.get(request.conn);
+ if (conn === undefined || conn.closed)
+ return domain(closeResult, "Closed");
+ dropConnection(conn);
+ return ok(closeResult, { tag: "V1" });
+ };
+ return {
+ async handleFrame(bytes) {
+ const request = decodeFrame(bytes);
+ if (request.payload.messageType === MESSAGE_TYPE_CANCEL) {
+ if (request.payload.traitId !== JAM_PEER_TRANSPORT_DIAL.trait || request.payload.value.length !== 0) {
+ throw new Error("Invalid cancellation frame");
+ }
+ // Only a dial can outlast one host tick: it may wait on a permission
+ // prompt and a handshake. The dial itself answers `Cancelled`; a
+ // CANCEL naming nothing in flight lost the race and is dropped.
+ if (hasIds(request, JAM_PEER_TRANSPORT_DIAL))
+ pendingDials.get(request.requestId)?.(cancelledReply);
+ return new Uint8Array();
+ }
+ if (request.payload.messageType !== MESSAGE_TYPE_REQUEST) {
+ throw new Error("Invalid request frame");
+ }
+ if (closed)
+ return reply(request, frameworkResult.enc({ success: false, value: { tag: "Denied" } }));
+ if (hasIds(request, SYSTEM_HANDSHAKE)) {
+ let handshake;
+ try {
+ handshake = exact(T.VersionedHostHandshakeRequest, request.payload.value);
+ }
+ catch {
+ return reply(request, frameworkResult.enc({ success: false, value: { tag: "MalformedFrame", value: { reason: "invalid handshake" } } }));
+ }
+ if (handshake.value.codecVersion !== TRUAPI_CODEC_VERSION) {
+ return reply(request, handshakeResult.enc({ success: false, value: { tag: "Domain", value: { tag: "V1", value: { tag: "UnsupportedProtocolVersion" } } } }));
+ }
+ negotiated = true;
+ return reply(request, handshakeResult.enc({ success: true, value: { tag: "V1" } }));
+ }
+ if (request.payload.traitId !== JAM_PEER_TRANSPORT_DIAL.trait) {
+ return reply(request, frameworkResult.enc({ success: false, value: { tag: "Denied" } }));
+ }
+ const malformed = () => reply(request, frameworkResult.enc({ success: false, value: { tag: "MalformedFrame", value: { reason: "invalid peer-transport request" } } }));
+ try {
+ if (hasIds(request, JAM_PEER_TRANSPORT_DIAL)) {
+ const value = exact(T.VersionedHostJamPeerTransportDialRequest, request.payload.value).value;
+ if (!negotiated)
+ return reply(request, domain(dialResult, "NotGranted"));
+ // Two live dials sharing an id leave neither addressable by CANCEL;
+ // like the core dispatcher, the second is dropped unanswered.
+ if (pendingDials.has(request.requestId))
+ return new Uint8Array();
+ return reply(request, await dialFrame(request.requestId, value));
+ }
+ if (hasIds(request, JAM_PEER_TRANSPORT_OPEN)) {
+ const value = exact(T.VersionedHostJamPeerTransportOpenRequest, request.payload.value).value;
+ return reply(request, negotiated ? await open(value) : domain(openResult, "NotGranted"));
+ }
+ if (hasIds(request, JAM_PEER_TRANSPORT_SEND)) {
+ const value = exact(T.VersionedHostJamPeerTransportSendRequest, request.payload.value).value;
+ return reply(request, negotiated ? await send(value) : domain(sendResult, "Closed"));
+ }
+ if (hasIds(request, JAM_PEER_TRANSPORT_RECV)) {
+ const value = exact(T.VersionedHostJamPeerTransportRecvRequest, request.payload.value).value;
+ return reply(request, negotiated ? recv(value) : domain(recvResult, "Closed"));
+ }
+ if (hasIds(request, JAM_PEER_TRANSPORT_RESET)) {
+ const value = exact(T.VersionedHostJamPeerTransportResetRequest, request.payload.value).value;
+ return reply(request, negotiated ? reset(value) : domain(resetResult, "Closed"));
+ }
+ if (hasIds(request, JAM_PEER_TRANSPORT_CLOSE)) {
+ const value = exact(T.VersionedHostJamPeerTransportCloseRequest, request.payload.value).value;
+ return reply(request, negotiated ? close(value) : domain(closeResult, "Closed"));
+ }
+ if (hasIds(request, JAM_PEER_TRANSPORT_EVENTS)) {
+ exact(T.VersionedHostJamPeerTransportEventsRequest, request.payload.value);
+ if (!negotiated)
+ return reply(request, domain(eventsResult, "NotGranted"));
+ return reply(request, ok(eventsResult, { tag: "V1", value: { events: events.splice(0, events.length) } }));
+ }
+ }
+ catch {
+ return malformed();
+ }
+ return reply(request, frameworkResult.enc({ success: false, value: { tag: "Unsupported" } }));
+ },
+ close() {
+ closed = true;
+ const denied = frameworkResult.enc({ success: false, value: { tag: "Denied" } });
+ for (const withdraw of [...pendingDials.values()])
+ withdraw(denied);
+ for (const conn of [...connections.values()])
+ dropConnection(conn);
+ events.length = 0;
+ },
+ };
+}
diff --git a/vendor/truapi/dist/playground/codegen/services.js b/vendor/truapi/dist/playground/codegen/services.js
index 77429c86..6cd3bc78 100644
--- a/vendor/truapi/dist/playground/codegen/services.js
+++ b/vendor/truapi/dist/playground/codegen/services.js
@@ -497,6 +497,83 @@ export const services = [
},
],
},
+ {
+ name: "Jam Peer Transport",
+ methods: [
+ {
+ name: "dial",
+ type: "unary",
+ signature: "dial(request: HostJamPeerTransportDialRequest): Promise>>",
+ docUrl: "api/jam_peer_transport/trait.JamPeerTransport.html#method.dial",
+ description: "Dial one peer. The host builds the ALPN from `genesis` and requires the\npeer certificate to carry `ed25519` (QUIC) or to hash to the\ncertificate derived from `p256` (WebTransport).",
+ requestDescription: "HostJamPeerTransportDialRequest",
+ exampleSource: 'const result = await truapi.jamPeerTransport.dial({\n genesis: "0x353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f",\n ip: "0x00000000000000000000ffff7f000001",\n port: 43000,\n ed25519: "0x0000000000000000000000000000000000000000000000000000000000000000",\n p256: undefined,\n});\nif (result.isOk()) console.log("connection:", result.value.conn);',
+ requestType: "host-jam-peer-transport-dial-request",
+ responseType: "host-jam-peer-transport-dial-response",
+ },
+ {
+ name: "open",
+ type: "unary",
+ signature: "open(request: HostJamPeerTransportOpenRequest): Promise>>",
+ docUrl: "api/jam_peer_transport/trait.JamPeerTransport.html#method.open",
+ description: "Open a bidirectional stream on a connection and send its kind byte.",
+ requestDescription: "HostJamPeerTransportOpenRequest",
+ exampleSource: 'const result = await truapi.jamPeerTransport.open({ conn: 0, kind: 0 });\nif (result.isOk()) console.log("stream:", result.value.stream);',
+ requestType: "host-jam-peer-transport-open-request",
+ responseType: "host-jam-peer-transport-open-response",
+ },
+ {
+ name: "send",
+ type: "unary",
+ signature: "send(request: HostJamPeerTransportSendRequest): Promise>>",
+ docUrl: "api/jam_peer_transport/trait.JamPeerTransport.html#method.send",
+ description: "Queue one message; the host prepends the `u32` little-endian length.",
+ requestDescription: "HostJamPeerTransportSendRequest",
+ exampleSource: 'const result = await truapi.jamPeerTransport.send({ stream: 0, message: "0x00", fin: false });\nconsole.log("sent:", result.isOk());',
+ requestType: "host-jam-peer-transport-send-request",
+ },
+ {
+ name: "recv",
+ type: "unary",
+ signature: "recv(request: HostJamPeerTransportRecvRequest): Promise>>",
+ docUrl: "api/jam_peer_transport/trait.JamPeerTransport.html#method.recv",
+ description: "Poll one complete message without blocking; the host strips the length.",
+ requestDescription: "HostJamPeerTransportRecvRequest",
+ exampleSource: 'const result = await truapi.jamPeerTransport.recv({ stream: 0, max: 1048576 });\nif (result.isOk()) console.log("message:", result.value.message, "fin:", result.value.fin);',
+ requestType: "host-jam-peer-transport-recv-request",
+ responseType: "host-jam-peer-transport-recv-response",
+ },
+ {
+ name: "reset",
+ type: "unary",
+ signature: "reset(request: HostJamPeerTransportResetRequest): Promise>>",
+ docUrl: "api/jam_peer_transport/trait.JamPeerTransport.html#method.reset",
+ description: "Abort a stream in both directions.",
+ requestDescription: "HostJamPeerTransportResetRequest",
+ exampleSource: 'const result = await truapi.jamPeerTransport.reset({ stream: 0 });\nconsole.log("reset:", result.isOk());',
+ requestType: "host-jam-peer-transport-reset-request",
+ },
+ {
+ name: "close",
+ type: "unary",
+ signature: "close(request: HostJamPeerTransportCloseRequest): Promise>>",
+ docUrl: "api/jam_peer_transport/trait.JamPeerTransport.html#method.close",
+ description: "Close a connection and every stream on it.",
+ requestDescription: "HostJamPeerTransportCloseRequest",
+ exampleSource: 'const result = await truapi.jamPeerTransport.close({ conn: 0 });\nconsole.log("closed:", result.isOk());',
+ requestType: "host-jam-peer-transport-close-request",
+ },
+ {
+ name: "events",
+ type: "unary",
+ signature: "events(): Promise>>",
+ docUrl: "api/jam_peer_transport/trait.JamPeerTransport.html#method.events",
+ description: "Drain connection, stream-finish and inbound-stream events.",
+ exampleSource: 'const result = await truapi.jamPeerTransport.events();\nif (result.isOk()) console.log("events:", result.value.events);',
+ responseType: "host-jam-peer-transport-events-response",
+ },
+ ],
+ },
{
name: "Local Storage",
methods: [
diff --git a/vendor/truapi/dist/playground/codegen/truapi-dts.d.ts b/vendor/truapi/dist/playground/codegen/truapi-dts.d.ts
index 56958adb..4aba3855 100644
--- a/vendor/truapi/dist/playground/codegen/truapi-dts.d.ts
+++ b/vendor/truapi/dist/playground/codegen/truapi-dts.d.ts
@@ -1 +1 @@
-export declare const truapiDts = "// neverthrow (inlined)\n\ninterface ErrorConfig {\n withStackTrace: boolean;\n}\n\ndeclare class ResultAsync implements PromiseLike> {\n private _promise;\n constructor(res: Promise>);\n static fromSafePromise(promise: PromiseLike): ResultAsync;\n static fromPromise(promise: PromiseLike, errorFn: (e: unknown) => E): ResultAsync;\n static fromThrowable(fn: (...args: A) => Promise, errorFn?: (err: unknown) => E): (...args: A) => ResultAsync;\n static combine, ...ResultAsync[]]>(asyncResultList: T): CombineResultAsyncs;\n static combine[]>(asyncResultList: T): CombineResultAsyncs;\n static combineWithAllErrors, ...ResultAsync[]]>(asyncResultList: T): CombineResultsWithAllErrorsArrayAsync;\n static combineWithAllErrors[]>(asyncResultList: T): CombineResultsWithAllErrorsArrayAsync;\n map(f: (t: T) => A | Promise): ResultAsync;\n andThrough(f: (t: T) => Result | ResultAsync): ResultAsync;\n andTee(f: (t: T) => unknown): ResultAsync;\n orTee(f: (t: E) => unknown): ResultAsync;\n mapErr(f: (e: E) => U | Promise): ResultAsync;\n andThen>(f: (t: T) => R): ResultAsync, InferErrTypes | E>;\n andThen>(f: (t: T) => R): ResultAsync, InferAsyncErrTypes | E>;\n andThen(f: (t: T) => Result | ResultAsync): ResultAsync;\n orElse>(f: (e: E) => R): ResultAsync | T, InferErrTypes>;\n orElse>(f: (e: E) => R): ResultAsync | T, InferAsyncErrTypes>;\n orElse(f: (e: E) => Result | ResultAsync): ResultAsync;\n match(ok: (t: T) => A, _err: (e: E) => B): Promise;\n unwrapOr(t: A): Promise;\n /**\n * @deprecated will be removed in 9.0.0.\n *\n * You can use `safeTry` without this method.\n * @example\n * ```typescript\n * safeTry(async function* () {\n * const okValue = yield* yourResult\n * })\n * ```\n * Emulates Rust's `?` operator in `safeTry`'s body. See also `safeTry`.\n */\n safeUnwrap(): AsyncGenerator, T>;\n then(successCallback?: (res: Result) => A | PromiseLike, failureCallback?: (reason: unknown) => B | PromiseLike): PromiseLike;\n [Symbol.asyncIterator](): AsyncGenerator, T>;\n}\ndeclare function okAsync(value: T): ResultAsync;\ndeclare function okAsync(value: void): ResultAsync;\ndeclare function errAsync(err: E): ResultAsync;\ndeclare function errAsync(err: void): ResultAsync;\ndeclare const fromPromise: typeof ResultAsync.fromPromise;\ndeclare const fromSafePromise: typeof ResultAsync.fromSafePromise;\ndeclare const fromAsyncThrowable: typeof ResultAsync.fromThrowable;\ndeclare type CombineResultAsyncs[]> = IsLiteralArray extends 1 ? TraverseAsync> : ResultAsync, ExtractErrAsyncTypes[number]>;\ndeclare type CombineResultsWithAllErrorsArrayAsync[]> = IsLiteralArray extends 1 ? TraverseWithAllErrorsAsync> : ResultAsync, ExtractErrAsyncTypes[number][]>;\ndeclare type UnwrapAsync = IsLiteralArray extends 1 ? Writable extends [infer H, ...infer Rest] ? H extends PromiseLike ? HI extends Result ? [Dedup, ...UnwrapAsync] : never : never : [] : T extends Array ? A extends PromiseLike ? HI extends Result ? Ok[] : never : never : never;\ndeclare type TraverseAsync = IsLiteralArray extends 1 ? Combine extends [infer Oks, infer Errs] ? ResultAsync, MembersToUnion> : never : T extends Array ? Combine, Depth> extends [infer Oks, infer Errs] ? Oks extends unknown[] ? Errs extends unknown[] ? ResultAsync, MembersToUnion> : ResultAsync, Errs> : Errs extends unknown[] ? ResultAsync> : ResultAsync : never : never;\ndeclare type TraverseWithAllErrorsAsync = TraverseAsync extends ResultAsync ? ResultAsync : never;\ndeclare type Writable = T extends ReadonlyArray ? [...T] : T;\n\ndeclare type ExtractOkTypes[]> = {\n [idx in keyof T]: T[idx] extends Result ? U : never;\n};\ndeclare type ExtractOkAsyncTypes[]> = {\n [idx in keyof T]: T[idx] extends ResultAsync ? U : never;\n};\ndeclare type ExtractErrTypes[]> = {\n [idx in keyof T]: T[idx] extends Result ? E : never;\n};\ndeclare type ExtractErrAsyncTypes[]> = {\n [idx in keyof T]: T[idx] extends ResultAsync ? E : never;\n};\ndeclare type InferOkTypes = R extends Result ? T : never;\ndeclare type InferErrTypes = R extends Result ? E : never;\ndeclare type InferAsyncOkTypes = R extends ResultAsync ? T : never;\ndeclare type InferAsyncErrTypes = R extends ResultAsync ? E : never;\n\ndeclare namespace Result {\n /**\n * Wraps a function with a try catch, creating a new function with the same\n * arguments but returning `Ok` if successful, `Err` if the function throws\n *\n * @param fn function to wrap with ok on success or err on failure\n * @param errorFn when an error is thrown, this will wrap the error result if provided\n */\n function fromThrowable any, E>(fn: Fn, errorFn?: (e: unknown) => E): (...args: Parameters) => Result, E>;\n function combine, ...Result[]]>(resultList: T): CombineResults;\n function combine[]>(resultList: T): CombineResults;\n function combineWithAllErrors, ...Result[]]>(resultList: T): CombineResultsWithAllErrorsArray;\n function combineWithAllErrors[]>(resultList: T): CombineResultsWithAllErrorsArray;\n}\ndeclare type Result = Ok | Err;\ndeclare function ok