From eca524af6352fc5627669d5e120acf73d59b1a5c Mon Sep 17 00:00:00 2001 From: babkenmes Date: Wed, 29 Jul 2026 16:24:48 +0400 Subject: [PATCH 01/24] feat(widget-cdn): add Azure Blob sandbox deployment --- .../workflows/widget-cdn-azure-sandbox.yml | 122 +++++++++ apps/widget-cdn/.gitignore | 2 +- apps/widget-cdn/AZURE_SETUP.md | 226 ++++++++++++++++ apps/widget-cdn/README.md | 17 +- apps/widget-cdn/package.json | 2 + apps/widget-cdn/scripts/azure-lib.mjs | 147 +++++++++++ .../scripts/azure-release-scripts.test.mjs | 227 +++++++++++++++++ apps/widget-cdn/scripts/build-manifest.mjs | 11 +- apps/widget-cdn/scripts/cdn-layout.mjs | 13 + apps/widget-cdn/scripts/deploy-azure.mjs | 241 ++++++++++++++++++ apps/widget-cdn/scripts/rollback-azure.mjs | 108 ++++++++ packages/widget/js/src/manifest.ts | 17 +- 12 files changed, 1112 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/widget-cdn-azure-sandbox.yml create mode 100644 apps/widget-cdn/AZURE_SETUP.md create mode 100644 apps/widget-cdn/scripts/azure-lib.mjs create mode 100644 apps/widget-cdn/scripts/azure-release-scripts.test.mjs create mode 100644 apps/widget-cdn/scripts/deploy-azure.mjs create mode 100644 apps/widget-cdn/scripts/rollback-azure.mjs diff --git a/.github/workflows/widget-cdn-azure-sandbox.yml b/.github/workflows/widget-cdn-azure-sandbox.yml new file mode 100644 index 0000000000..23573231f6 --- /dev/null +++ b/.github/workflows/widget-cdn-azure-sandbox.yml @@ -0,0 +1,122 @@ +name: widget-cdn — Azure sandbox deploy + +# Branch-only personal-account validation. Production remains on the R2 +# workflow until the production Azure identity and migration compatibility +# are ready. +on: + push: + branches: + - codex/widget-cdn-azure-sandbox + workflow_dispatch: + inputs: + promote: + description: "Publish this build to the rolling v1 channel" + type: boolean + required: false + default: true + +concurrency: + group: widget-cdn-azure-sandbox + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +jobs: + deploy: + runs-on: ubuntu-latest + env: + # These identify the isolated personal Azure sandbox; they are not + # credentials. Authentication is a short-lived, branch-scoped OIDC token. + AZURE_CLIENT_ID: 69ca1ebc-8381-4b6d-864a-497053bdd827 + AZURE_TENANT_ID: 8e8e42bf-64ee-4057-99ab-8288126d4ebb + AZURE_SUBSCRIPTION_ID: f60908b5-2c18-438c-89c5-510a01d2a802 + AZURE_STORAGE_ACCOUNT: layerswapcdntest + AZURE_STORAGE_CONTAINER: widget-cdn + LAYERSWAP_PROMOTE: ${{ github.event_name != 'workflow_dispatch' || inputs.promote != false }} + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v5 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build workspace packages + run: pnpm build:packages + + - name: Test widget release tooling + run: | + pnpm --filter @layerswap/widget-cdn test + pnpm --filter @layerswap/widget-js test + pnpm --filter @layerswap/widget-react check:types + + - name: Compute build metadata + run: | + echo "LAYERSWAP_GIT_SHA=${GITHUB_SHA}" >> "$GITHUB_ENV" + echo "LAYERSWAP_BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV" + BUILD_ID=$(node --input-type=module -e "import { resolveBuildIdentity } from './apps/widget-cdn/scripts/build-id.mjs'; process.stdout.write(resolveBuildIdentity('./apps/widget-cdn').buildId)") + echo "LAYERSWAP_BUILD_ID=${BUILD_ID}" >> "$GITHUB_ENV" + + # Generate a new sandbox-only key for every run. The Blob delivery path + # can be tested without storing or exposing the production signing key. + - name: Generate ephemeral sandbox signing key + id: key + run: | + umask 077 + KEY_PATH="$RUNNER_TEMP/layerswap-azure-test-signing.pem" + openssl ecparam -name prime256v1 -genkey -noout -out "$KEY_PATH" + echo "key-path=$KEY_PATH" >> "$GITHUB_OUTPUT" + + - name: Build signed sandbox artifact + working-directory: apps/widget-cdn + env: + LAYERSWAP_PRIVATE_KEY_PEM: ${{ steps.key.outputs.key-path }} + run: pnpm build + + - name: Log in to personal Azure with OIDC + uses: azure/login@v2 + with: + client-id: ${{ env.AZURE_CLIENT_ID }} + tenant-id: ${{ env.AZURE_TENANT_ID }} + subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} + + - name: Upload to Azure Blob Storage and promote channel + working-directory: apps/widget-cdn + run: pnpm deploy:azure + + - name: Wipe sandbox signing key + if: always() + run: rm -f "$RUNNER_TEMP/layerswap-azure-test-signing.pem" + + - name: Smoke-test the public Blob channel + if: ${{ env.LAYERSWAP_PROMOTE != 'false' }} + run: | + set -euo pipefail + BASE_URL="https://${AZURE_STORAGE_ACCOUNT}.blob.core.windows.net/${AZURE_STORAGE_CONTAINER}" + MANIFEST_URL="${BASE_URL}/v1/manifest.json" + curl -fsSL -H "Origin: https://example.com" -D /tmp/headers \ + "$MANIFEST_URL" -o /tmp/manifest.json + grep -i 'access-control-allow-origin' /tmp/headers \ + || { echo "::error::Azure Blob CORS header is missing"; exit 1; } + node -e " + const m = JSON.parse(require('fs').readFileSync('/tmp/manifest.json','utf8')); + if (!m.signature) throw new Error('manifest has no sandbox signature'); + if (m.buildId !== '${LAYERSWAP_BUILD_ID}') { + throw new Error('channel has ' + m.buildId + ', expected ${LAYERSWAP_BUILD_ID}'); + } + const remote = new URL(m.remoteEntry, '${MANIFEST_URL}').href; + if (!remote.includes('/${LAYERSWAP_BUILD_ID}/remoteEntry.js')) { + throw new Error('remoteEntry is not immutable: ' + remote); + } + console.log('Azure channel OK', { buildId: m.buildId, remote }); + " diff --git a/apps/widget-cdn/.gitignore b/apps/widget-cdn/.gitignore index 06ad5e2453..f008887c58 100644 --- a/apps/widget-cdn/.gitignore +++ b/apps/widget-cdn/.gitignore @@ -1,3 +1,3 @@ # Placeholder signing material — replace with KMS-managed key before # pointing real integrators at this build. NEVER commit private keys. -.keys/manifest-private.pem +.keys/*.pem diff --git a/apps/widget-cdn/AZURE_SETUP.md b/apps/widget-cdn/AZURE_SETUP.md new file mode 100644 index 0000000000..47f1f7e985 --- /dev/null +++ b/apps/widget-cdn/AZURE_SETUP.md @@ -0,0 +1,226 @@ +# Azure Blob Storage sandbox setup + +This is the low-cost personal-account setup for validating widget delivery +before Layerswap provisions Azure Front Door and a private production origin. +It uses a dedicated public Blob container because browsers must fetch the +manifest and JavaScript anonymously. + +Do not reuse this account for unrelated private data. + +## 1. Create the storage account + +In the [Azure portal](https://portal.azure.com): + +1. Open **Storage accounts** and select **Create**. +2. Create or choose a resource group, for example + `widget-cdn-sandbox-rg`. +3. Enter a globally unique lowercase account name, for example + `yournamewidgetcdn`. +4. Choose: + - Preferred storage type: **Blob storage or Azure Data Lake Storage**. + - Region: a nearby region. + - Performance: **Standard**. + - Redundancy: **LRS** for this temporary sandbox. +5. On **Advanced**: + - Require secure transfer: **Enabled**. + - Allow enabling anonymous access on individual containers: **Enabled**. + - Enable storage account key access: **Disabled**. + - Default to Microsoft Entra authorization in the Azure portal: **Enabled**. + - Minimum TLS version: **1.2**. + - Hierarchical namespace: **Disabled**. +6. On **Networking**, allow public network access from all networks for the + sandbox. +7. Select **Review + create**, then **Create**. + +## 2. Protect accidental overwrites + +Open the storage account, then **Data management → Data protection**: + +- Enable blob soft delete with a 7-day retention period. +- Enable container soft delete with a 7-day retention period. +- Enable blob versioning. + +The only routinely overwritten blob is `v1/manifest.json`. Versioning gives +the sandbox a recovery history for channel promotions. + +## 3. Grant your user deployment access + +Being the subscription owner does not always grant Blob data access. + +1. Open **Access control (IAM)** on the storage account. +2. Select **Add → Add role assignment**. +3. Choose **Storage Blob Data Contributor**. +4. Assign access to **User, group, or service principal**. +5. Select your personal Azure user and save. + +The role can take several minutes to propagate. Wait for it before continuing +because the portal is configured to use Microsoft Entra authorization instead +of the storage account key. + +## 4. Create the public container + +1. Open **Data storage → Containers**. +2. Select **+ Container**. +3. Name it `widget-cdn`. +4. Set anonymous access to **Blob (anonymous read access for blobs only)**. + Do not choose container-level listing access. +5. Select **Create**. + +If the anonymous-access option is disabled, open +**Settings → Configuration**, enable **Allow Blob anonymous access**, save, +and retry. + +The public base URL will be: + +```text +https://.blob.core.windows.net/widget-cdn +``` + +## 5. Configure browser CORS + +Open **Settings → Resource sharing (CORS)**. In the **Blob service** row add: + +| Field | Value | +| --------------- | ------------------------------------------------ | +| Allowed origins | `*` | +| Allowed methods | `GET`, `HEAD`, `OPTIONS` | +| Allowed headers | `*` | +| Exposed headers | `ETag,Content-Length,Content-Type,Cache-Control` | +| Max age | `86400` | + +Save the rule. This is required for the cross-origin manifest fetch and +`crossorigin="anonymous"` JavaScript loads. + +## 6. Authenticate locally + +Install Azure CLI on macOS if `az` is not already available: + +```bash +brew install azure-cli +az login +az account set --subscription "" +az account show --output table +``` + +The deploy script asks Azure CLI for a short-lived Microsoft Entra access +token. It does not use a storage account key or connection string. + +## 7. Build a sandbox artifact + +Create a temporary signing key. It is ignored by git and is intentionally not +the production trust key: + +```bash +openssl ecparam -name prime256v1 -genkey -noout \ + -out apps/widget-cdn/.keys/azure-test-private.pem + +LAYERSWAP_PRIVATE_KEY_PEM=.keys/azure-test-private.pem \ + pnpm --filter @layerswap/widget-cdn build +``` + +The sandbox key will not verify against the public key compiled into the +production loader. Use verification-off only in the internal example harness. + +## 8. Deploy and promote `v1` + +```bash +AZURE_STORAGE_ACCOUNT="" \ +AZURE_STORAGE_CONTAINER="widget-cdn" \ +pnpm --filter @layerswap/widget-cdn deploy:azure +``` + +The command uploads immutable objects first, publishes the immutable manifest +last, then copies the signed manifest to `v1/manifest.json`. + +Upload without changing `v1`: + +```bash +LAYERSWAP_PROMOTE=false \ +AZURE_STORAGE_ACCOUNT="" \ +AZURE_STORAGE_CONTAINER="widget-cdn" \ +pnpm --filter @layerswap/widget-cdn deploy:azure +``` + +Rollback/promotion to an already-uploaded build: + +```bash +AZURE_STORAGE_ACCOUNT="" \ +AZURE_STORAGE_CONTAINER="widget-cdn" \ +pnpm --filter @layerswap/widget-cdn rollback:azure -- \ + v1 1.7.0- +``` + +## 9. Smoke-test Blob delivery + +```bash +export WIDGET_AZURE_BASE="https://.blob.core.windows.net/widget-cdn" + +curl -fsSL -H "Origin: http://127.0.0.1:3001" \ + -D /tmp/widget-azure-headers \ + "$WIDGET_AZURE_BASE/v1/manifest.json" \ + -o /tmp/widget-azure-manifest.json + +grep -i "access-control-allow-origin" /tmp/widget-azure-headers +node -e " + const m = require('/tmp/widget-azure-manifest.json'); + console.log({ + buildId: m.buildId, + remoteEntry: new URL(m.remoteEntry, process.env.WIDGET_AZURE_BASE + '/v1/manifest.json').href + }); +" +``` + +Then run the internal React host against Azure with signature verification +disabled for this temporary key: + +```bash +VITE_LAYERSWAP_MANIFEST="$WIDGET_AZURE_BASE/v1/manifest.json" \ +VITE_LAYERSWAP_VERIFY=false \ +pnpm --filter widget-react-host-example dev -- \ + --host 127.0.0.1 --port 3001 --no-open +``` + +Open `http://127.0.0.1:3001`. + +## 10. GitHub Actions branch sandbox + +After local delivery works, `.github/workflows/widget-cdn-azure-sandbox.yml` +deploys this feature branch to the personal account through OIDC. It does not +require a GitHub environment or stored Azure/signing secret. + +In Azure Portal: + +1. Open **Microsoft Entra ID → App registrations → New registration**. +2. Name it `layerswap-widget-cdn-github-test`, use the current tenant only, + and leave the redirect URI empty. +3. Open **Certificates & secrets → Federated credentials → Add credential**. +4. Select the GitHub Actions scenario and configure: + - Organization: `layerswap` + - Repository: `layerswapapp` + - Entity type: **Branch** + - Branch: `codex/widget-cdn-azure-sandbox` +5. Assign this application **Storage Blob Data Contributor** on only the + `widget-cdn` container. + +The workflow is triggered by pushes to that branch. Azure accepts an OIDC +token only when its subject is exactly: + +```text +repo:layerswap/layerswapapp:ref:refs/heads/codex/widget-cdn-azure-sandbox +``` + +The Azure client, tenant, subscription, storage account, and container +identifiers in the sandbox workflow are not credentials. The workflow +generates a disposable signing key for each run. Production should instead +use a protected GitHub environment and the production signing key or KMS. + +## Production follow-up + +The Layerswap account should replace this public sandbox origin with Azure +Front Door and preferably a private Blob origin. The final rollout also needs: + +- GitHub Actions OIDC instead of a developer's Azure CLI session. +- The production manifest signing key/KMS. +- A stable custom domain such as `cdn.layerswap.io`. +- A transition redirect for loaders that still contain the old `workers.dev` + URL. diff --git a/apps/widget-cdn/README.md b/apps/widget-cdn/README.md index ce5204ac18..94933076dc 100644 --- a/apps/widget-cdn/README.md +++ b/apps/widget-cdn/README.md @@ -12,6 +12,10 @@ runtime delivery, plus the Cloudflare **Worker + R2** edge that serves it. Consumed by `@layerswap/widget-react` (React hosts) and `@layerswap/widget-js` (framework-agnostic hosts). +Azure Blob Storage migration is being developed side-by-side with the current +production R2 path. See [AZURE_SETUP.md](./AZURE_SETUP.md) for the personal +sandbox setup and `deploy:azure` / `rollback:azure` commands. + ## Versioning model Every build is published to an **immutable, buildId-named prefix** in R2 and @@ -45,12 +49,13 @@ automatically. Pinned URLs exist for Layerswap's own release mechanics reachable from a loader via the internal `__LAYERSWAP_WIDGET_MANIFEST__` override global — a build/test seam, not a supported integrator option. -The loader follows the redirect and resolves the relative `remoteEntry` against -the **final** URL, so the remote anchors at the immutable build path. The -remote loads its content-hashed chunks from the stable `/assets/` namespace, -and the signed manifest registers SRI for both locations. **Rollback / -roll-forward is a pointer flip** — no rebuild, no re-upload (see -`scripts/rollback-r2.mjs`). +The manifest uses a build-addressed relative `remoteEntry` (for example +`../1.5.0-abc123def456/remoteEntry.js`), so the remote anchors at the immutable +build path whether the manifest arrives through the Worker's redirect or from +an Azure rolling-channel blob. The remote loads its content-hashed chunks from +the stable `/assets/` namespace, and the signed manifest registers SRI for both +locations. **Rollback / roll-forward is a pointer flip** — no rebuild, no +re-upload (see `scripts/rollback-r2.mjs` or `scripts/rollback-azure.mjs`). A build's immutable identity is its **buildId** — the `@layerswap/widget` version plus the git sha (`1.5.0-abc123def456`, see `scripts/build-id.mjs`) — diff --git a/apps/widget-cdn/package.json b/apps/widget-cdn/package.json index 5c59419c37..03ebf32079 100644 --- a/apps/widget-cdn/package.json +++ b/apps/widget-cdn/package.json @@ -9,6 +9,8 @@ "verify-manifest": "node scripts/verify-manifest.mjs", "deploy:r2": "node scripts/deploy-r2.mjs", "rollback:r2": "node scripts/rollback-r2.mjs", + "deploy:azure": "node scripts/deploy-azure.mjs", + "rollback:azure": "node scripts/rollback-azure.mjs", "test": "node --test scripts/*.test.mjs", "worker:dev": "wrangler dev --config worker/wrangler.toml", "worker:deploy": "wrangler deploy --config worker/wrangler.toml", diff --git a/apps/widget-cdn/scripts/azure-lib.mjs b/apps/widget-cdn/scripts/azure-lib.mjs new file mode 100644 index 0000000000..84f6f2f4c3 --- /dev/null +++ b/apps/widget-cdn/scripts/azure-lib.mjs @@ -0,0 +1,147 @@ +// Shared Azure Blob Storage helpers for deploy + rollback. +// +// Authentication uses a short-lived Microsoft Entra token from Azure CLI: +// - locally: run `az login` +// - GitHub Actions: run `azure/login` with OIDC first +// +// Required environment: +// AZURE_STORAGE_ACCOUNT +// AZURE_STORAGE_CONTAINER (default: widget-cdn) +// +// AZURE_STORAGE_BLOB_ENDPOINT is optional and supports sovereign clouds or a +// custom Blob endpoint. No account key or connection string is required. + +import { execFileSync } from "node:child_process"; + +const CONTENT_TYPES = { + js: "text/javascript; charset=utf-8", + mjs: "text/javascript; charset=utf-8", + css: "text/css; charset=utf-8", + json: "application/json; charset=utf-8", + map: "application/json; charset=utf-8", +}; + +export function contentTypeFor(name) { + const ext = name.split(".").pop()?.toLowerCase() ?? ""; + return CONTENT_TYPES[ext] ?? "application/octet-stream"; +} + +function requireEnv(env, name) { + const value = env[name]; + if (!value) throw new Error(`[azure] missing required env var ${name}`); + return value; +} + +export function makeClient(env = process.env) { + const account = requireEnv(env, "AZURE_STORAGE_ACCOUNT"); + const containerName = env.AZURE_STORAGE_CONTAINER || "widget-cdn"; + const endpoint = + env.AZURE_STORAGE_BLOB_ENDPOINT || + `https://${account}.blob.core.windows.net`; + let accessToken = env.AZURE_STORAGE_ACCESS_TOKEN; + if (!accessToken) { + try { + accessToken = execFileSync( + "az", + [ + "account", + "get-access-token", + "--resource", + env.AZURE_STORAGE_TOKEN_RESOURCE || "https://storage.azure.com/", + "--query", + "accessToken", + "--output", + "tsv", + ], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ).trim(); + } catch (err) { + const detail = err?.stderr?.toString().trim(); + throw new Error( + "[azure] could not obtain an Azure CLI access token. Run `az login` first" + + `${detail ? `: ${detail}` : "."}`, + ); + } + } + if (!accessToken) + throw new Error("[azure] Azure CLI returned an empty access token"); + return { + account, + containerName, + endpoint: endpoint.replace(/\/+$/, ""), + accessToken, + }; +} + +function objectUrl(ctx, key) { + const container = encodeURIComponent(ctx.containerName); + const encodedKey = key.split("/").map(encodeURIComponent).join("/"); + return `${ctx.endpoint}/${container}/${encodedKey}`; +} + +async function blobRequest(ctx, key, init = {}) { + const headers = new Headers(init.headers); + headers.set("Authorization", `Bearer ${ctx.accessToken}`); + headers.set("x-ms-date", new Date().toUTCString()); + headers.set("x-ms-version", "2023-11-03"); + return fetch(objectUrl(ctx, key), { ...init, headers }); +} + +async function throwResponseError(operation, key, response) { + const message = (await response.text()).trim(); + throw new Error( + `[azure] ${operation} ${key} failed with ${response.status} ${response.statusText}` + + `${message ? `: ${message}` : ""}`, + ); +} + +export async function objectExists(ctx, key) { + const response = await blobRequest(ctx, key, { method: "HEAD" }); + if (response.status === 404) return false; + if (!response.ok) await throwResponseError("HEAD", key, response); + return true; +} + +export async function putObject( + ctx, + key, + body, + { contentType, cacheControl } = {}, +) { + const headers = { + "Content-Type": "application/octet-stream", + "x-ms-blob-type": "BlockBlob", + "x-ms-blob-content-type": contentType ?? contentTypeFor(key), + }; + if (cacheControl) headers["x-ms-blob-cache-control"] = cacheControl; + const response = await blobRequest(ctx, key, { + method: "PUT", + headers, + body, + }); + if (!response.ok) await throwResponseError("PUT", key, response); +} + +export async function readJsonObject(ctx, key) { + const response = await blobRequest(ctx, key); + if (response.status === 404) return undefined; + if (!response.ok) await throwResponseError("GET", key, response); + const value = await response.json(); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`[azure] ${key} does not contain a JSON object`); + } + return value; +} + +export async function writeChannelManifest(ctx, channel, manifest) { + await putObject( + ctx, + `${channel}/manifest.json`, + Buffer.from(JSON.stringify(manifest, null, 2)), + { + contentType: "application/json; charset=utf-8", + // Same propagation target as the current Worker's short-lived 302. + cacheControl: "public, max-age=60, must-revalidate", + }, + ); +} diff --git a/apps/widget-cdn/scripts/azure-release-scripts.test.mjs b/apps/widget-cdn/scripts/azure-release-scripts.test.mjs new file mode 100644 index 0000000000..ce06940cd0 --- /dev/null +++ b/apps/widget-cdn/scripts/azure-release-scripts.test.mjs @@ -0,0 +1,227 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { putObject } from "./azure-lib.mjs"; +import { deployAzureBuild } from "./deploy-azure.mjs"; +import { rollbackAzureChannel } from "./rollback-azure.mjs"; +import { ASSET_BASE, remoteEntryForBuild } from "./cdn-layout.mjs"; + +const silentLogger = { log() {}, warn() {} }; + +test("Azure Blob PUT uses Entra auth, encoded keys, and blob HTTP properties", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + let request; + globalThis.fetch = async (url, init) => { + request = { url, init }; + return new Response(null, { status: 201 }); + }; + + await putObject( + { + endpoint: "https://test.blob.core.windows.net", + containerName: "widget-cdn", + accessToken: "short-lived-token", + }, + "build id/remoteEntry.js", + Buffer.from("remote"), + { + contentType: "text/javascript; charset=utf-8", + cacheControl: "public, max-age=31536000, immutable", + }, + ); + + assert.equal( + request.url, + "https://test.blob.core.windows.net/widget-cdn/build%20id/remoteEntry.js", + ); + const headers = new Headers(request.init.headers); + assert.equal(headers.get("authorization"), "Bearer short-lived-token"); + assert.equal(headers.get("x-ms-blob-type"), "BlockBlob"); + assert.equal( + headers.get("x-ms-blob-content-type"), + "text/javascript; charset=utf-8", + ); + assert.equal( + headers.get("x-ms-blob-cache-control"), + "public, max-age=31536000, immutable", + ); +}); + +function deployFixture(t, identity, manifestOverrides = {}) { + const root = mkdtempSync(join(tmpdir(), "layerswap-deploy-azure-")); + const dist = join(root, "dist", identity.buildId); + mkdirSync(dist, { recursive: true }); + writeFileSync( + join(dist, "manifest.json"), + JSON.stringify({ + ...identity, + remoteEntry: remoteEntryForBuild(identity.buildId), + assetBase: ASSET_BASE, + chunks: {}, + signature: "signed", + ...manifestOverrides, + }), + ); + t.after(() => rmSync(root, { recursive: true, force: true })); + return root; +} + +test("Azure deploy rejects a channel-relative remote before creating a client", async (t) => { + const identity = { + buildId: "1.7.0-0123456789ab", + version: "1.7.0", + channel: "v1", + gitSha: "0123456789abcdef", + }; + const root = deployFixture(t, identity, { remoteEntry: "./remoteEntry.js" }); + mkdirSync(join(root, "dist", "assets")); + let clientsCreated = 0; + + await assert.rejects( + deployAzureBuild({ + root, + identity, + logger: silentLogger, + createClient() { + clientsCreated += 1; + return {}; + }, + }), + /remoteEntry/, + ); + assert.equal(clientsCreated, 0); +}); + +test("Azure deploy reuses shared assets and promotes the signed manifest", async (t) => { + const identity = { + buildId: "1.7.0-0123456789ab", + version: "1.7.0", + channel: "v1", + gitSha: "0123456789abcdef", + }; + const root = deployFixture(t, identity); + const buildDir = join(root, "dist", identity.buildId); + const assetDir = join(root, "dist", "assets"); + const assetName = "837.0123456789abcdef.js"; + mkdirSync(assetDir); + writeFileSync(join(buildDir, "remoteEntry.js"), "remote"); + writeFileSync(join(assetDir, assetName), "chunk"); + + const uploadedKeys = []; + const promotions = []; + const result = await deployAzureBuild({ + root, + identity, + logger: silentLogger, + createClient: () => ({ account: "test", containerName: "widget-cdn" }), + objectExists: async (_ctx, key) => key === `assets/${assetName}`, + putObject: async (_ctx, key) => uploadedKeys.push(key), + readJsonObject: async () => ({ buildId: "1.6.0-aaaaaaaaaaaa" }), + writeChannelManifest: async (_ctx, channel, manifest) => { + promotions.push({ channel, buildId: manifest.buildId }); + }, + }); + + assert.deepEqual(result, { uploaded: 2, reused: 1, promoted: true }); + assert.deepEqual(uploadedKeys, [ + `${identity.buildId}/remoteEntry.js`, + `${identity.buildId}/manifest.json`, + ]); + assert.deepEqual(promotions, [{ channel: "v1", buildId: identity.buildId }]); +}); + +test("Azure deploy publishes the immutable manifest after all payloads", async (t) => { + const identity = { + buildId: "1.7.0-0123456789ab", + version: "1.7.0", + channel: "v1", + gitSha: "0123456789abcdef", + }; + const root = deployFixture(t, identity); + const buildDir = join(root, "dist", identity.buildId); + const assetDir = join(root, "dist", "assets"); + mkdirSync(assetDir); + writeFileSync(join(buildDir, "remoteEntry.js"), "remote"); + writeFileSync(join(assetDir, "one.0123456789abcdef.js"), "one"); + writeFileSync(join(assetDir, "two.fedcba9876543210.js"), "two"); + + let activePayloadUploads = 0; + const completedKeys = []; + await deployAzureBuild({ + root, + identity, + env: { LAYERSWAP_PROMOTE: "false" }, + logger: silentLogger, + uploadConcurrency: 2, + createClient: () => ({ account: "test", containerName: "widget-cdn" }), + objectExists: async () => false, + putObject: async (_ctx, key) => { + if (key.endsWith("/manifest.json")) { + assert.equal(activePayloadUploads, 0); + } else { + activePayloadUploads += 1; + await new Promise((resolve) => setTimeout(resolve, 10)); + activePayloadUploads -= 1; + } + completedKeys.push(key); + }, + }); + + assert.equal(completedKeys.at(-1), `${identity.buildId}/manifest.json`); +}); + +test("Azure rollback promotes a valid immutable manifest", async () => { + const buildId = "1.7.0-0123456789ab"; + const writes = []; + const result = await rollbackAzureChannel({ + channel: "v1", + buildId, + ctx: {}, + logger: silentLogger, + readManifest: async (_ctx, key) => + key.startsWith(buildId) + ? { + buildId, + channel: "v1", + remoteEntry: remoteEntryForBuild(buildId), + expiresAt: new Date( + Date.now() + 7 * 24 * 60 * 60 * 1000, + ).toISOString(), + } + : { buildId: "1.6.0-aaaaaaaaaaaa" }, + writeManifest: async (_ctx, channel, manifest) => { + writes.push({ channel, buildId: manifest.buildId }); + }, + }); + + assert.deepEqual(result, { changed: true, previous: "1.6.0-aaaaaaaaaaaa" }); + assert.deepEqual(writes, [{ channel: "v1", buildId }]); +}); + +test("Azure rollback rejects a manifest that cannot resolve from the channel path", async () => { + const buildId = "1.7.0-0123456789ab"; + let writes = 0; + await assert.rejects( + rollbackAzureChannel({ + channel: "v1", + buildId, + ctx: {}, + logger: silentLogger, + readManifest: async () => ({ + buildId, + channel: "v1", + remoteEntry: "./remoteEntry.js", + }), + writeManifest: async () => { + writes += 1; + }, + }), + /valid Azure manifest/, + ); + assert.equal(writes, 0); +}); diff --git a/apps/widget-cdn/scripts/build-manifest.mjs b/apps/widget-cdn/scripts/build-manifest.mjs index b50f20c590..e028a1d3cb 100644 --- a/apps/widget-cdn/scripts/build-manifest.mjs +++ b/apps/widget-cdn/scripts/build-manifest.mjs @@ -18,7 +18,7 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createSign, createPrivateKey, createHash } from 'node:crypto'; import { resolveBuildIdentity } from './build-id.mjs'; -import { ASSET_BASE, ASSET_DIRECTORY } from './cdn-layout.mjs'; +import { ASSET_BASE, ASSET_DIRECTORY, remoteEntryForBuild } from './cdn-layout.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); @@ -54,10 +54,11 @@ if (!existsSync(ASSET_DIST)) { process.exit(1); } -// remoteEntry.js sits at the build-directory root by Rspack config. Kept -// origin-relative so the same signed manifest works whether served at its -// version path or reached via the rolling-channel redirect. -const remoteEntry = './remoteEntry.js'; +// This resolves to the immutable build prefix from BOTH publication paths: +// //manifest.json (immutable) and //manifest.json (rolling). +// Cloudflare's existing redirect still lands at the same URL, while Azure can +// promote by copying this signed manifest directly to the rolling path. +const remoteEntry = remoteEntryForBuild(buildId); // Hash every JS file in the build directory and record under the // filename. The browser will use these via SRI when MF loads the scripts. diff --git a/apps/widget-cdn/scripts/cdn-layout.mjs b/apps/widget-cdn/scripts/cdn-layout.mjs index 1249dc8051..b8630efe51 100644 --- a/apps/widget-cdn/scripts/cdn-layout.mjs +++ b/apps/widget-cdn/scripts/cdn-layout.mjs @@ -22,3 +22,16 @@ export function deploymentKey(buildId, relativePath) { ? `${ASSET_DIRECTORY}/${relativePath}` : `${buildId}/${relativePath}`; } + +/** + * A signed production manifest is published twice on Azure: + * - //manifest.json (immutable) + * - //manifest.json (rolling pointer) + * + * This relative URL resolves to the same immutable remote from either + * location, so promotion can atomically copy the already-signed manifest + * bytes without an edge redirect or a second signature. + */ +export function remoteEntryForBuild(buildId) { + return `../${buildId}/remoteEntry.js`; +} diff --git a/apps/widget-cdn/scripts/deploy-azure.mjs b/apps/widget-cdn/scripts/deploy-azure.mjs new file mode 100644 index 0000000000..806941827a --- /dev/null +++ b/apps/widget-cdn/scripts/deploy-azure.mjs @@ -0,0 +1,241 @@ +#!/usr/bin/env node +// Publish a signed build to Azure Blob Storage. +// +// Immutable build controls live at // and content-hashed chunks live +// in /assets/. Promotion atomically copies the same signed manifest to +// //manifest.json; its build-addressed remoteEntry keeps all code on +// immutable URLs without needing a Cloudflare Worker or Azure Function. + +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + contentTypeFor, + makeClient, + objectExists, + putObject, + readJsonObject, + writeChannelManifest, +} from "./azure-lib.mjs"; +import { resolveBuildIdentity } from "./build-id.mjs"; +import { + ASSET_BASE, + ASSET_DIRECTORY, + deploymentKey, + isSharedAsset, + remoteEntryForBuild, +} from "./cdn-layout.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, ".."); + +export function validateAzureManifest(manifest, identity) { + const mismatches = [ + ["buildId", manifest.buildId, identity.buildId], + ["version", manifest.version, identity.version], + ["channel", manifest.channel, identity.channel], + ["gitSha", manifest.gitSha, identity.gitSha], + ["assetBase", manifest.assetBase, ASSET_BASE], + [ + "remoteEntry", + manifest.remoteEntry, + remoteEntryForBuild(identity.buildId), + ], + ].filter(([, actual, expected]) => actual !== expected); + + if (mismatches.length > 0) { + const details = mismatches + .map( + ([field, actual, expected]) => + `${field}: ${JSON.stringify(actual)} !== ${JSON.stringify(expected)}`, + ) + .join(", "); + throw new Error( + `[deploy-azure] manifest identity does not match this build: ${details}`, + ); + } +} + +function listFiles(dir) { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...listFiles(full)); + else if (entry.isFile()) out.push(full); + } + return out; +} + +async function runWithConcurrency(items, limit, task) { + if (items.length === 0) return; + const workerCount = Math.min(Math.max(1, Math.floor(limit)), items.length); + let cursor = 0; + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (cursor < items.length) { + const item = items[cursor]; + cursor += 1; + await task(item); + } + }), + ); +} + +export async function deployAzureBuild(options = {}) { + const root = options.root ?? ROOT; + const identity = options.identity ?? resolveBuildIdentity(root); + const env = options.env ?? process.env; + const logger = options.logger ?? console; + const createClient = options.createClient ?? (() => makeClient(env)); + const exists = options.objectExists ?? objectExists; + const upload = options.putObject ?? putObject; + const readJson = options.readJsonObject ?? readJsonObject; + const publishChannel = options.writeChannelManifest ?? writeChannelManifest; + const uploadConcurrency = options.uploadConcurrency ?? 8; + const dist = join(root, "dist", identity.buildId); + const assetDist = join(root, "dist", ASSET_DIRECTORY); + const manifestPath = join(dist, "manifest.json"); + + if (!existsSync(manifestPath)) { + throw new Error( + `[deploy-azure] missing ${manifestPath} — run \`pnpm build\` first.`, + ); + } + + let manifest; + try { + manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch (err) { + throw new Error( + `[deploy-azure] failed to read manifest: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (!manifest.signature) { + throw new Error( + "[deploy-azure] refusing to deploy an UNSIGNED manifest. " + + "Build with LAYERSWAP_PRIVATE_KEY_PEM set.", + ); + } + + // Validate before Azure credentials are read or a client is created. + validateAzureManifest(manifest, identity); + if (!existsSync(assetDist)) { + throw new Error( + `[deploy-azure] missing shared asset output ${assetDist} — run \`pnpm build\` first.`, + ); + } + + const ctx = createClient(); + if (await exists(ctx, `${identity.buildId}/manifest.json`)) { + if (env.ALLOW_OVERWRITE !== "1") { + throw new Error( + `[deploy-azure] build ${identity.buildId} is already published (immutable). ` + + "Deploy from a new commit, or set ALLOW_OVERWRITE=1 to force.", + ); + } + } + + const files = [ + ...listFiles(dist).map((file) => ({ file, root: dist, shared: false })), + ...listFiles(assetDist).map((file) => ({ + file, + root: assetDist, + shared: true, + })), + ].sort((a, b) => a.file.localeCompare(b.file)); + + const publishableFiles = files.map(({ file, root: fileRoot, shared }) => { + const rel = relative(fileRoot, file).split(/[\\/]/).join("/"); + if (shared && !isSharedAsset(rel)) { + throw new Error( + `[deploy-azure] refusing non-content-hashed file in ${ASSET_DIRECTORY}/: ${rel}`, + ); + } + return { + file, + rel, + shared, + key: shared + ? deploymentKey(identity.buildId, rel) + : `${identity.buildId}/${rel}`, + }; + }); + const manifestFile = publishableFiles.find( + ({ file }) => file === manifestPath, + ); + const payloadFiles = publishableFiles.filter( + ({ file }) => file !== manifestPath, + ); + if (!manifestFile) { + throw new Error( + `[deploy-azure] missing ${manifestPath} from publishable files.`, + ); + } + + let uploaded = 0; + let reused = 0; + logger.log( + `[deploy-azure] publishing ${files.length} file(s) to ` + + `${ctx.account ?? "(account)"}/${ctx.containerName ?? "(container)"} …`, + ); + + const publishFile = async ({ file, rel, shared, key }) => { + if (shared && env.ALLOW_OVERWRITE !== "1" && (await exists(ctx, key))) { + reused += 1; + logger.log(` = ${key} (already published)`); + return; + } + + const body = readFileSync(file); + await upload(ctx, key, body, { + contentType: contentTypeFor(rel), + cacheControl: "public, max-age=31536000, immutable", + }); + uploaded += 1; + logger.log(` ↑ ${key} (${body.length} bytes)`); + }; + + // Publishing the manifest last ensures an interrupted deployment can + // never expose a build whose payload is incomplete. + await runWithConcurrency(payloadFiles, uploadConcurrency, publishFile); + await publishFile(manifestFile); + + logger.log( + `[deploy-azure] published build ${identity.buildId} ` + + `(${uploaded} uploaded, ${reused} reused).`, + ); + + if (env.LAYERSWAP_PROMOTE === "false") { + logger.log( + `[deploy-azure] LAYERSWAP_PROMOTE=false — channel ${identity.channel} NOT changed. ` + + "Promote later with:", + ); + logger.log( + ` node scripts/rollback-azure.mjs ` + + `${identity.channel} ${identity.buildId}`, + ); + return { uploaded, reused, promoted: false }; + } + + const channelKey = `${identity.channel}/manifest.json`; + const previous = await readJson(ctx, channelKey); + await publishChannel(ctx, identity.channel, manifest); + logger.log( + `[deploy-azure] channel ${identity.channel}: ` + + `${previous?.buildId ?? "(none)"} → ${identity.buildId}`, + ); + logger.log(`[deploy-azure] live at /${identity.channel}/manifest.json`); + return { uploaded, reused, promoted: true }; +} + +const entryUrl = process.argv[1] + ? pathToFileURL(resolve(process.argv[1])).href + : undefined; +if (entryUrl === import.meta.url) { + try { + await deployAzureBuild(); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + } +} diff --git a/apps/widget-cdn/scripts/rollback-azure.mjs b/apps/widget-cdn/scripts/rollback-azure.mjs new file mode 100644 index 0000000000..135583410e --- /dev/null +++ b/apps/widget-cdn/scripts/rollback-azure.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +// Promote a previously-published immutable Azure build by copying its signed +// manifest to the rolling //manifest.json path. + +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + makeClient, + readJsonObject, + writeChannelManifest, +} from "./azure-lib.mjs"; +import { isValidBuildId } from "./build-id.mjs"; +import { remoteEntryForBuild } from "./cdn-layout.mjs"; + +export async function rollbackAzureChannel(options) { + const { + channel, + buildId, + logger = console, + readManifest = readJsonObject, + writeManifest = writeChannelManifest, + } = options; + + if (!channel || !/^v\d+$/.test(channel) || !isValidBuildId(buildId)) { + throw new Error("[rollback-azure] invalid channel or buildId"); + } + + const ctx = options.ctx ?? makeClient(options.env); + const manifestKey = `${buildId}/manifest.json`; + const manifest = await readManifest(ctx, manifestKey); + if (!manifest) { + throw new Error( + `[rollback-azure] build ${buildId} is not published ` + + `(no ${manifestKey} in container).`, + ); + } + if ( + manifest.buildId !== buildId || + manifest.channel !== channel || + manifest.remoteEntry !== remoteEntryForBuild(buildId) + ) { + throw new Error( + `[rollback-azure] ${buildId} does not contain a valid Azure manifest ` + + `for ${channel}.`, + ); + } + + if (typeof manifest.expiresAt === "string") { + const expiresMs = Date.parse(manifest.expiresAt); + const dayMs = 24 * 60 * 60 * 1000; + if (!Number.isNaN(expiresMs) && expiresMs <= Date.now()) { + throw new Error( + `[rollback-azure] build ${buildId} expired at ${manifest.expiresAt} — ` + + "loaders will refuse it. Re-publish and re-sign the build.", + ); + } + if (!Number.isNaN(expiresMs) && expiresMs - Date.now() < 3 * dayMs) { + logger.warn( + `[rollback-azure] warning: build ${buildId} expires ` + + `${manifest.expiresAt} (<3 days).`, + ); + } + } else { + logger.warn( + `[rollback-azure] warning: build ${buildId} has no expiresAt; ` + + "verifying loaders will reject it.", + ); + } + + const current = await readManifest(ctx, `${channel}/manifest.json`); + if (current?.buildId === buildId) { + logger.log( + `[rollback-azure] channel ${channel} already points at ${buildId} — nothing to do.`, + ); + return { changed: false, previous: buildId }; + } + + await writeManifest(ctx, channel, manifest); + logger.log( + `[rollback-azure] channel ${channel}: ` + + `${current?.buildId ?? "(none)"} → ${buildId}`, + ); + logger.log( + "[rollback-azure] propagates within ~60s (channel manifest cache).", + ); + return { changed: true, previous: current?.buildId }; +} + +const entryUrl = process.argv[1] + ? pathToFileURL(resolve(process.argv[1])).href + : undefined; +if (entryUrl === import.meta.url) { + const [, , channel, buildId] = process.argv; + if (!channel || !/^v\d+$/.test(channel) || !isValidBuildId(buildId)) { + console.error("usage: node scripts/rollback-azure.mjs "); + console.error( + " e.g. node scripts/rollback-azure.mjs v1 1.7.0-abc123def456", + ); + process.exitCode = 1; + } else { + try { + await rollbackAzureChannel({ channel, buildId }); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + } + } +} diff --git a/packages/widget/js/src/manifest.ts b/packages/widget/js/src/manifest.ts index c4edde8a92..d84f687dce 100644 --- a/packages/widget/js/src/manifest.ts +++ b/packages/widget/js/src/manifest.ts @@ -52,11 +52,11 @@ export type Manifest = { /** * Absolute or manifest-relative URL to the remoteEntry.js. * - * Kept origin-relative (`"./remoteEntry.js"`) so the same signed bytes work - * whether the manifest is fetched directly at its immutable version path - * (`/1.5.0/manifest.json`) or reached via a rolling-channel redirect - * (`/v1/manifest.json` → 302 → `/1.5.0/manifest.json`). The loader resolves - * it against the manifest's FINAL (post-redirect) URL — see `resolveSource`. + * Production builds use a build-addressed relative URL such as + * `"../1.5.0-abc123def456/remoteEntry.js"`. The same signed bytes therefore + * work at the immutable path (`//manifest.json`), behind the + * existing rolling-channel redirect, or copied directly to + * `/v1/manifest.json` by Azure promotion. */ remoteEntry: string; /** @@ -243,10 +243,9 @@ export type FetchedManifest = { manifest: Manifest; /** * The FINAL URL the manifest was served from, after any HTTP redirects. - * When a rolling channel (`/v1/manifest.json`) 302-redirects to an - * immutable build (`/1.5.0/manifest.json`), this is the latter — so - * resolving the relative `remoteEntry` against it anchors the remote (and - * every chunk it loads) at the immutable version path, not the channel root. + * The remoteEntry is build-addressed in production, so it anchors at the + * immutable build whether the manifest was redirected there or served + * directly from the rolling Azure channel path. */ url: string; }; From 52c6b0c1f16bea359bdc951b0e3cd7f1ea7606ab Mon Sep 17 00:00:00 2001 From: babkenmes Date: Wed, 29 Jul 2026 19:04:16 +0400 Subject: [PATCH 02/24] feat(widget): add shared protocol versioning --- .changeset/config.json | 13 +- .github/workflows/widget-cdn-deploy.yml | 127 ++++++++++++++---- apps/widget-cdn/AZURE_SETUP.md | 6 +- apps/widget-cdn/README.md | 47 +++++-- apps/widget-cdn/package.json | 2 +- apps/widget-cdn/rspack.config.mjs | 2 + apps/widget-cdn/scripts/azure-lib.mjs | 5 +- .../scripts/azure-release-scripts.test.mjs | 30 ++++- apps/widget-cdn/scripts/build-id.mjs | 11 +- apps/widget-cdn/scripts/build-id.test.mjs | 4 +- apps/widget-cdn/scripts/build-manifest.mjs | 2 + apps/widget-cdn/scripts/deploy-azure.mjs | 2 + apps/widget-cdn/scripts/deploy-r2.mjs | 2 + .../scripts/protocol-contract.test.mjs | 20 +++ .../scripts/release-scripts.test.mjs | 18 ++- apps/widget-cdn/scripts/rollback-azure.mjs | 8 +- apps/widget-cdn/scripts/rollback-r2.mjs | 12 +- packages/widget/core/src/Models/Theme.ts | 2 +- .../core/src/components/Common/Sceletons.tsx | 6 +- packages/widget/js/package.json | 4 +- packages/widget/js/src/index.ts | 25 ++-- packages/widget/js/src/loader.ts | 8 ++ packages/widget/js/src/manifest.ts | 33 ++--- packages/widget/js/src/types.ts | 4 +- .../widget/js/tests/loader-assets.test.mjs | 1 + .../js/tests/loader-singleflight.test.mjs | 1 + packages/widget/js/tests/protocol.test.mjs | 50 +++++++ packages/widget/react/README.md | 13 +- packages/widget/react/package.json | 4 +- packages/widget/react/src/index.ts | 4 +- packages/widget/types/package.json | 4 +- packages/widget/types/src/config.ts | 24 ++-- packages/widget/types/src/index.ts | 1 + packages/widget/types/src/protocol.ts | 29 ++++ pnpm-lock.yaml | 4 +- 35 files changed, 403 insertions(+), 125 deletions(-) create mode 100644 apps/widget-cdn/scripts/protocol-contract.test.mjs create mode 100644 packages/widget/js/tests/protocol.test.mjs create mode 100644 packages/widget/types/src/protocol.ts diff --git a/.changeset/config.json b/.changeset/config.json index 1d5f47fa4e..bea3ddda52 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,6 +8,11 @@ "@layerswap/wallet-*", "@layerswap/wallets", "@layerswap/utils" + ], + [ + "@layerswap/widget-react", + "@layerswap/widget-js", + "@layerswap/widget-types" ] ], "linked": [], @@ -18,15 +23,11 @@ "version": true, "tag": false }, - "ignore": [ - "@layerswap/bridge", - "widget-playground", - "layerswap-explorer" - ], + "ignore": ["@layerswap/bridge", "widget-playground", "layerswap-explorer"], "snapshot": { "useCalculatedVersion": true }, "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { "onlyUpdatePeerDependentsWhenOutOfRange": true } -} \ No newline at end of file +} diff --git a/.github/workflows/widget-cdn-deploy.yml b/.github/workflows/widget-cdn-deploy.yml index 5bf5eae47c..69691c7f5d 100644 --- a/.github/workflows/widget-cdn-deploy.yml +++ b/.github/workflows/widget-cdn-deploy.yml @@ -3,7 +3,7 @@ name: widget-cdn — build, sign, deploy # Production builds of `apps/widget-cdn` go through CI so the signing key never # lives outside `secrets.LAYERSWAP_PRIVATE_KEY_PEM`. The build is uploaded to # Cloudflare R2 under an immutable per-build prefix and the rolling channel -# pointer (`channels.json`) is flipped to it. The Cloudflare Worker in +# pointer (`channels.json`) can be promoted to it in a separate dispatch. The Cloudflare Worker in # `apps/widget-cdn/worker` serves R2 and 302-redirects `/vN/*` to the current # build. This workflow is the only path to a production CDN deploy. @@ -14,21 +14,26 @@ on: # just `main`) once the CDN is verified end-to-end. branches: [main, babkenmes/widget-cdn-delivery] paths: - - 'apps/widget-cdn/**' - - 'packages/widget/**' - - 'packages/wallets/**' - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - - '.github/workflows/widget-cdn-deploy.yml' + - "apps/widget-cdn/**" + - "packages/widget/**" + - "packages/wallets/**" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - ".github/workflows/widget-cdn-deploy.yml" workflow_dispatch: inputs: - promote: - description: 'Flip the rolling channel to this build (uncheck to upload only)' - type: boolean + promote_build_id: + description: "Existing immutable buildId to promote; leave empty to build/upload a candidate" + type: string + required: false + default: "" + promote_channel: + description: "Compatibility channel for promote_build_id" + type: string required: false - default: true + default: "v1" deploy_worker: - description: 'Also deploy the Cloudflare Worker (edge router)' + description: "Also deploy the Cloudflare Worker (edge router)" type: boolean required: false default: false @@ -46,6 +51,7 @@ permissions: jobs: deploy: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.promote_build_id == '' }} runs-on: ubuntu-latest environment: widget-cdn-production env: @@ -54,8 +60,9 @@ jobs: R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} R2_BUCKET: ${{ vars.R2_BUCKET || 'layerswap-widget-cdn' }} - # Promotion defaults to true on push; honors the dispatch toggle otherwise. - LAYERSWAP_PROMOTE: ${{ (github.event_name == 'workflow_dispatch' && inputs.promote == false) && 'false' || 'true' }} + # Upload and promotion are deliberately separate. Main pushes publish an + # immutable candidate; a later dispatch promotes its existing buildId. + LAYERSWAP_PROMOTE: "false" # Published builds are immutable, but the buildId embeds the commit sha # (see apps/widget-cdn/scripts/build-id.mjs), so every push gets a fresh # prefix — only literally re-deploying the SAME commit collides. For that @@ -128,11 +135,11 @@ jobs: working-directory: apps/widget-cdn run: pnpm verify-manifest - # Upload the immutable build to R2 and (unless promote=false) flip the - # rolling channel pointer. Refuses to overwrite an already-published + # Upload the immutable candidate without touching a rolling channel. + # Refuses to overwrite an already-published # buildId, so re-running a deploy for the same commit is a no-op error # rather than a silent clobber — any new commit gets a fresh buildId. - - name: Upload to R2 + promote channel + - name: Upload candidate to R2 working-directory: apps/widget-cdn run: pnpm deploy:r2 @@ -150,26 +157,25 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} run: pnpm worker:deploy - # End-to-end check against the live CDN: the rolling channel resolves, - # the manifest is reachable with CORS, signed, and kill-switch off. - - name: Smoke-test the live channel - if: ${{ env.LAYERSWAP_PROMOTE != 'false' && vars.CDN_BASE_URL != '' }} + # End-to-end check against the immutable candidate before it is eligible + # for promotion. + - name: Smoke-test the uploaded candidate + if: ${{ vars.CDN_BASE_URL != '' }} run: | set -euo pipefail VERSION=$(node -e "console.log(require('./apps/widget-cdn/node_modules/@layerswap/widget/package.json').version)") - CHANNEL="v${VERSION%%.*}" - MANIFEST_URL="${{ vars.CDN_BASE_URL }}/${CHANNEL}/manifest.json" - echo "Fetching (following redirects) $MANIFEST_URL" + MANIFEST_URL="${{ vars.CDN_BASE_URL }}/${LAYERSWAP_BUILD_ID}/manifest.json" + echo "Fetching $MANIFEST_URL" curl -fsSL -L -H "Origin: https://example.com" -D /tmp/headers "$MANIFEST_URL" -o /tmp/manifest.json grep -i 'access-control-allow-origin' /tmp/headers || { echo "::error::CDN missing CORS header"; exit 1; } node -e " const m = JSON.parse(require('fs').readFileSync('/tmp/manifest.json','utf8')); if (!m.signature) { console.error('live manifest has no signature'); process.exit(1); } if (m.killSwitch) { console.error('live manifest has killSwitch=true'); process.exit(1); } - if (m.buildId !== '${LAYERSWAP_BUILD_ID}') { console.error('live channel still on build', m.buildId, '— expected ${LAYERSWAP_BUILD_ID}'); process.exit(1); } - if (m.version !== '${VERSION}') { console.error('live channel still on', m.version, '— expected ${VERSION}'); process.exit(1); } - if (m.gitSha !== '${GITHUB_SHA}') { console.error('live channel still on commit', m.gitSha, '— expected ${GITHUB_SHA}'); process.exit(1); } - console.log('live channel OK buildId=', m.buildId, ' version=', m.version, ' gitSha=', m.gitSha); + if (m.buildId !== '${LAYERSWAP_BUILD_ID}') { console.error('candidate has build', m.buildId, '— expected ${LAYERSWAP_BUILD_ID}'); process.exit(1); } + if (m.version !== '${VERSION}') { console.error('candidate has version', m.version, '— expected ${VERSION}'); process.exit(1); } + if (m.gitSha !== '${GITHUB_SHA}') { console.error('candidate has commit', m.gitSha, '— expected ${GITHUB_SHA}'); process.exit(1); } + console.log('candidate OK buildId=', m.buildId, ' version=', m.version, ' gitSha=', m.gitSha); " - name: Summary @@ -182,7 +188,7 @@ jobs: echo "" echo "- Build id: \`${BUILD_ID}\`" echo "- Version: \`${VERSION}\`" - echo "- Channel promoted: \`${LAYERSWAP_PROMOTE}\`" + echo "- Channel promoted: \`false\` (candidate only)" echo "- Commit: \`${GITHUB_SHA}\`" } >> "$GITHUB_STEP_SUMMARY" @@ -216,3 +222,66 @@ jobs: else gh issue create --repo "${{ github.repository }}" --title "$TITLE" --body-file "$RUNNER_TEMP/alert-body.md" fi + + promote: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.promote_build_id != '' }} + runs-on: ubuntu-latest + environment: widget-cdn-production + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_BUCKET: ${{ vars.R2_BUCKET || 'layerswap-widget-cdn' }} + PROMOTE_BUILD_ID: ${{ inputs.promote_build_id }} + PROMOTE_CHANNEL: ${{ inputs.promote_channel }} + + steps: + - uses: actions/checkout@v5 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v5 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build shared widget protocol + run: pnpm --filter @layerswap/widget-types build + + # rollback-r2 is also the promotion primitive: it validates that the + # immutable target exists, belongs to the requested protocol channel, + # and has a usable validity window before changing channels.json. + - name: Promote existing candidate + working-directory: apps/widget-cdn + run: node scripts/rollback-r2.mjs "$PROMOTE_CHANNEL" "$PROMOTE_BUILD_ID" + + - name: Smoke-test promoted channel + if: ${{ vars.CDN_BASE_URL != '' }} + run: | + set -euo pipefail + MANIFEST_URL="${{ vars.CDN_BASE_URL }}/${PROMOTE_CHANNEL}/manifest.json" + curl -fsSL -L -H "Origin: https://example.com" -D /tmp/headers "$MANIFEST_URL" -o /tmp/manifest.json + grep -i 'access-control-allow-origin' /tmp/headers || { echo "::error::CDN missing CORS header"; exit 1; } + node -e " + const m = JSON.parse(require('fs').readFileSync('/tmp/manifest.json','utf8')); + const expectedProtocol = Number(process.env.PROMOTE_CHANNEL.slice(1)); + const actualProtocol = m.protocolMajor ?? Number(String(m.channel).slice(1)); + if (!m.signature) { console.error('live manifest has no signature'); process.exit(1); } + if (m.killSwitch) { console.error('live manifest has killSwitch=true'); process.exit(1); } + if (m.buildId !== process.env.PROMOTE_BUILD_ID) { console.error('live channel has', m.buildId, '— expected', process.env.PROMOTE_BUILD_ID); process.exit(1); } + if (actualProtocol !== expectedProtocol) { console.error('live protocol is', actualProtocol, '— expected', expectedProtocol); process.exit(1); } + console.log('promotion OK channel=', process.env.PROMOTE_CHANNEL, ' buildId=', m.buildId); + " + + - name: Summary + if: always() + run: | + { + echo "## widget-cdn promotion" + echo "" + echo "- Channel: \`${PROMOTE_CHANNEL}\`" + echo "- Build id: \`${PROMOTE_BUILD_ID}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/apps/widget-cdn/AZURE_SETUP.md b/apps/widget-cdn/AZURE_SETUP.md index 47f1f7e985..7c91b74700 100644 --- a/apps/widget-cdn/AZURE_SETUP.md +++ b/apps/widget-cdn/AZURE_SETUP.md @@ -130,7 +130,11 @@ pnpm --filter @layerswap/widget-cdn deploy:azure ``` The command uploads immutable objects first, publishes the immutable manifest -last, then copies the signed manifest to `v1/manifest.json`. +last, then copies the signed manifest to `v1/manifest.json`. The channel blob +is written with `Cache-Control: no-store, max-age=0`; immutable build files and +shared assets retain their one-year immutable cache policy. If Front Door or +another CDN is later added, configure the manifest route to honor the origin +header or disable caching for `*/manifest.json`. Upload without changing `v1`: diff --git a/apps/widget-cdn/README.md b/apps/widget-cdn/README.md index 94933076dc..2414643514 100644 --- a/apps/widget-cdn/README.md +++ b/apps/widget-cdn/README.md @@ -38,13 +38,13 @@ The Worker serves two kinds of URL: | URL | Behavior | |---|---| | `…/v1/manifest.json` | **Rolling** — Worker 302-redirects to the current `v1` build. Auto-updates within ~60s of a channel flip. | -| `…/1.5.0-abc123def456/manifest.json` | **Pinned** — frozen forever at that exact build. | +| `…/1.5.0-abc123def456/manifest.json` | **Immutable** — exact build used for staging, rollback, and debugging. Its signed validity window still expires. | Integrators don't choose between them: the manifest URL is not a public knob. `@layerswap/widget-js` bakes in the rolling channel URL (`DEFAULT_MANIFEST_URL` in `src/manifest.ts`) and `resolveSource()` takes no arguments, so every integrator rides the channel and picks up pointer flips -automatically. Pinned URLs exist for Layerswap's own release mechanics +automatically. Immutable URLs exist for Layerswap's own release mechanics (staged releases, rollback targets, debugging a specific build) and are only reachable from a loader via the internal `__LAYERSWAP_WIDGET_MANIFEST__` override global — a build/test seam, not a supported integrator option. @@ -60,11 +60,12 @@ re-upload (see `scripts/rollback-r2.mjs` or `scripts/rollback-azure.mjs`). A build's immutable identity is its **buildId** — the `@layerswap/widget` version plus the git sha (`1.5.0-abc123def456`, see `scripts/build-id.mjs`) — because the deployed bytes also change with widget-cdn/wallets/widget-js/ -widget-react, none of which bump the widget version. The **version** remains -the host-facing compatibility number. A breaking change to -the embed/mount API or a required host singleton major (react/wagmi/viem) is -what warrants cutting a new major channel (`v2`); anything backward-compatible -ships within the existing channel. +widget-react, none of which bump the widget version. The manifest's +**protocolMajor** is the host-facing compatibility boundary and selects the +channel independently from the core implementation version. A breaking change +to the manifest, embed/mount API, exposed modules, or a required host runtime is +what warrants protocol `2`, loader package major `2`, and `/v2/`; compatible +changes continue rolling within `/v1/`. ## Dev @@ -129,7 +130,7 @@ pnpm worker:deploy # wrangler deploy ## Deploy ```bash -pnpm deploy:r2 # upload build controls + shared assets, then flip channel +pnpm deploy:r2 # local default: upload and promote LAYERSWAP_PROMOTE=false pnpm deploy:r2 # upload only (staged release) ALLOW_OVERWRITE=1 pnpm deploy:r2 # re-upload an existing build (escape hatch) @@ -146,11 +147,31 @@ new commit gets a fresh control-file prefix. ### CI deploy (production) Production deploys go through `.github/workflows/widget-cdn-deploy.yml`. The -signing key never leaves CI. The workflow: builds + signs → verifies the -signature against the bundled public key → uploads the immutable build to R2 → -flips the channel pointer → smoke-tests the live channel. The Worker is -deployed separately (manually, or via the `deploy_worker` dispatch input — it -rarely changes). +signing key never leaves CI. A main push builds, signs, verifies, uploads, and +smoke-tests an immutable candidate without changing a channel. Promote it in a +separate `workflow_dispatch` run by entering the reported `buildId` and its +channel; that job validates the existing target, flips the pointer, and +smoke-tests the live channel. The Worker is deployed separately and rarely +changes. + +### Azure Blob cache and retention policy + +Azure promotion overwrites only `//manifest.json`. It is published +with `Cache-Control: no-store, max-age=0` so new page loads observe a pointer +change without a CDN purge. Immutable build controls and content-addressed +assets remain `public, max-age=31536000, immutable`. + +If Azure Front Door or another company CDN sits in front of Blob Storage, its +route for `*/manifest.json` must honor the origin header or have caching +disabled. Build and asset routes should keep long-lived caching. + +Do not apply an age-only delete policy to `/assets/`: chunks are shared between +builds. Cleanup must retain every current supported-major build plus rollback +targets, mark the assets referenced by their manifests, and delete only +unreferenced assets after a grace period. Azure lifecycle rules are suitable +for old channel-blob versions and unpromoted candidates; reference-aware build +cleanup should be a separate scheduled job once production retention periods +are chosen. #### Required GitHub secrets diff --git a/apps/widget-cdn/package.json b/apps/widget-cdn/package.json index 03ebf32079..d3585788b2 100644 --- a/apps/widget-cdn/package.json +++ b/apps/widget-cdn/package.json @@ -11,7 +11,7 @@ "rollback:r2": "node scripts/rollback-r2.mjs", "deploy:azure": "node scripts/deploy-azure.mjs", "rollback:azure": "node scripts/rollback-azure.mjs", - "test": "node --test scripts/*.test.mjs", + "test": "pnpm --filter @layerswap/widget-types build && node --test scripts/*.test.mjs", "worker:dev": "wrangler dev --config worker/wrangler.toml", "worker:deploy": "wrangler deploy --config worker/wrangler.toml", "start": "node serve.mjs", diff --git a/apps/widget-cdn/rspack.config.mjs b/apps/widget-cdn/rspack.config.mjs index 1d851fa39b..a66c09daef 100644 --- a/apps/widget-cdn/rspack.config.mjs +++ b/apps/widget-cdn/rspack.config.mjs @@ -5,6 +5,7 @@ import rspack from '@rspack/core'; import { ModuleFederationPlugin } from '@module-federation/enhanced/rspack'; import { resolveBuildIdentity } from './scripts/build-id.mjs'; import { ASSET_BASE, CHUNK_HASH_LENGTH } from './scripts/cdn-layout.mjs'; +import { WIDGET_PROTOCOL_MAJOR } from '@layerswap/widget-types'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); @@ -66,6 +67,7 @@ const devManifestPlugin = { }, () => { const manifest = { + protocolMajor: WIDGET_PROTOCOL_MAJOR, version: '0.0.0-dev', remoteEntry: './remoteEntry.js', chunks: {}, diff --git a/apps/widget-cdn/scripts/azure-lib.mjs b/apps/widget-cdn/scripts/azure-lib.mjs index 84f6f2f4c3..442b1022c0 100644 --- a/apps/widget-cdn/scripts/azure-lib.mjs +++ b/apps/widget-cdn/scripts/azure-lib.mjs @@ -140,8 +140,9 @@ export async function writeChannelManifest(ctx, channel, manifest) { Buffer.from(JSON.stringify(manifest, null, 2)), { contentType: "application/json; charset=utf-8", - // Same propagation target as the current Worker's short-lived 302. - cacheControl: "public, max-age=60, must-revalidate", + // This is the only mutable public object. New page loads must observe a + // promotion immediately; immutable build files carry the long cache. + cacheControl: "no-store, max-age=0", }, ); } diff --git a/apps/widget-cdn/scripts/azure-release-scripts.test.mjs b/apps/widget-cdn/scripts/azure-release-scripts.test.mjs index ce06940cd0..ed6202a7f3 100644 --- a/apps/widget-cdn/scripts/azure-release-scripts.test.mjs +++ b/apps/widget-cdn/scripts/azure-release-scripts.test.mjs @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { putObject } from "./azure-lib.mjs"; +import { putObject, writeChannelManifest } from "./azure-lib.mjs"; import { deployAzureBuild } from "./deploy-azure.mjs"; import { rollbackAzureChannel } from "./rollback-azure.mjs"; import { ASSET_BASE, remoteEntryForBuild } from "./cdn-layout.mjs"; @@ -52,6 +52,32 @@ test("Azure Blob PUT uses Entra auth, encoded keys, and blob HTTP properties", a ); }); +test("Azure channel manifests disable browser and edge caching", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + let request; + globalThis.fetch = async (url, init) => { + request = { url, init }; + return new Response(null, { status: 201 }); + }; + + await writeChannelManifest( + { + endpoint: "https://test.blob.core.windows.net", + containerName: "widget-cdn", + accessToken: "short-lived-token", + }, + "v1", + { protocolMajor: 1, buildId: "1.7.0-0123456789ab" }, + ); + + assert.match(request.url, /\/v1\/manifest\.json$/); + const headers = new Headers(request.init.headers); + assert.equal(headers.get("x-ms-blob-cache-control"), "no-store, max-age=0"); +}); + function deployFixture(t, identity, manifestOverrides = {}) { const root = mkdtempSync(join(tmpdir(), "layerswap-deploy-azure-")); const dist = join(root, "dist", identity.buildId); @@ -60,6 +86,7 @@ function deployFixture(t, identity, manifestOverrides = {}) { join(dist, "manifest.json"), JSON.stringify({ ...identity, + protocolMajor: 1, remoteEntry: remoteEntryForBuild(identity.buildId), assetBase: ASSET_BASE, chunks: {}, @@ -215,6 +242,7 @@ test("Azure rollback rejects a manifest that cannot resolve from the channel pat readManifest: async () => ({ buildId, channel: "v1", + protocolMajor: 1, remoteEntry: "./remoteEntry.js", }), writeManifest: async () => { diff --git a/apps/widget-cdn/scripts/build-id.mjs b/apps/widget-cdn/scripts/build-id.mjs index 43941f9e2a..d478188aca 100644 --- a/apps/widget-cdn/scripts/build-id.mjs +++ b/apps/widget-cdn/scripts/build-id.mjs @@ -11,15 +11,16 @@ // deployed bytes WITHOUT bumping `@layerswap/widget` — keying immutability // on the version alone made every such deploy collide with the previously // published prefix. -// - `channel` — the rolling COMPATIBILITY pointer (`v` of the widget -// version). The Worker 302-redirects `/v1/*` to whatever buildId +// - `channel` — the rolling COMPATIBILITY pointer (`v`). +// The Worker 302-redirects `/v1/*` to whatever buildId // `channels.json` currently maps it to. // -// `version` stays the `@layerswap/widget` version — the host-facing -// compatibility number stamped into the manifest. +// `version` stays the `@layerswap/widget` implementation version stamped into +// the manifest. It does not select a CDN channel. import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { WIDGET_PROTOCOL_MAJOR } from '@layerswap/widget-types'; const VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; // `assets` is the shared content-addressed namespace and cannot also name a @@ -41,7 +42,7 @@ export function resolveBuildIdentity(root) { if (!VERSION_RE.test(version)) { throw new Error(`[build-id] invalid release version: ${JSON.stringify(version)}`); } - const channel = `v${version.split('.')[0]}`; + const channel = `v${WIDGET_PROTOCOL_MAJOR}`; const gitSha = process.env.LAYERSWAP_GIT_SHA || process.env.GITHUB_SHA || 'local'; const buildId = process.env.LAYERSWAP_RELEASE_ID || `${version}-${gitSha.slice(0, 12)}`; if (!isValidBuildId(buildId)) { diff --git a/apps/widget-cdn/scripts/build-id.test.mjs b/apps/widget-cdn/scripts/build-id.test.mjs index 8431d83bd3..599651416c 100644 --- a/apps/widget-cdn/scripts/build-id.test.mjs +++ b/apps/widget-cdn/scripts/build-id.test.mjs @@ -72,7 +72,7 @@ test('rejects malformed release versions', (t) => { } }); -test('accepts semver prerelease and build metadata', (t) => { +test('core semver does not select the compatibility channel', (t) => { const root = fixtureRoot(t); const identity = withEnv({ LAYERSWAP_RELEASE_VERSION: '2.0.0-beta.1+build.2', @@ -80,6 +80,6 @@ test('accepts semver prerelease and build metadata', (t) => { LAYERSWAP_GIT_SHA: 'abcdef0123456789', }, () => resolveBuildIdentity(root)); - assert.equal(identity.channel, 'v2'); + assert.equal(identity.channel, 'v1'); assert.equal(identity.buildId, '2.0.0-beta.1+build.2-abcdef012345'); }); diff --git a/apps/widget-cdn/scripts/build-manifest.mjs b/apps/widget-cdn/scripts/build-manifest.mjs index e028a1d3cb..d2e67c3ea5 100644 --- a/apps/widget-cdn/scripts/build-manifest.mjs +++ b/apps/widget-cdn/scripts/build-manifest.mjs @@ -17,6 +17,7 @@ import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createSign, createPrivateKey, createHash } from 'node:crypto'; +import { WIDGET_PROTOCOL_MAJOR } from '@layerswap/widget-types'; import { resolveBuildIdentity } from './build-id.mjs'; import { ASSET_BASE, ASSET_DIRECTORY, remoteEntryForBuild } from './cdn-layout.mjs'; @@ -108,6 +109,7 @@ function canonicalJSON(value) { const chunks = collectChunks([DIST, ASSET_DIST]); const manifest = { + protocolMajor: WIDGET_PROTOCOL_MAJOR, version, channel, buildId, diff --git a/apps/widget-cdn/scripts/deploy-azure.mjs b/apps/widget-cdn/scripts/deploy-azure.mjs index 806941827a..d7d25dff14 100644 --- a/apps/widget-cdn/scripts/deploy-azure.mjs +++ b/apps/widget-cdn/scripts/deploy-azure.mjs @@ -18,6 +18,7 @@ import { writeChannelManifest, } from "./azure-lib.mjs"; import { resolveBuildIdentity } from "./build-id.mjs"; +import { WIDGET_PROTOCOL_MAJOR } from "@layerswap/widget-types"; import { ASSET_BASE, ASSET_DIRECTORY, @@ -32,6 +33,7 @@ const ROOT = resolve(__dirname, ".."); export function validateAzureManifest(manifest, identity) { const mismatches = [ ["buildId", manifest.buildId, identity.buildId], + ["protocolMajor", manifest.protocolMajor, WIDGET_PROTOCOL_MAJOR], ["version", manifest.version, identity.version], ["channel", manifest.channel, identity.channel], ["gitSha", manifest.gitSha, identity.gitSha], diff --git a/apps/widget-cdn/scripts/deploy-r2.mjs b/apps/widget-cdn/scripts/deploy-r2.mjs index 221051cfb6..d3fb70872b 100644 --- a/apps/widget-cdn/scripts/deploy-r2.mjs +++ b/apps/widget-cdn/scripts/deploy-r2.mjs @@ -16,6 +16,7 @@ import { contentTypeFor, } from './r2-lib.mjs'; import { resolveBuildIdentity } from './build-id.mjs'; +import { WIDGET_PROTOCOL_MAJOR } from '@layerswap/widget-types'; import { ASSET_BASE, ASSET_DIRECTORY, @@ -29,6 +30,7 @@ const ROOT = resolve(__dirname, '..'); export function validateManifestIdentity(manifest, identity) { const mismatches = [ ['buildId', manifest.buildId, identity.buildId], + ['protocolMajor', manifest.protocolMajor, WIDGET_PROTOCOL_MAJOR], ['version', manifest.version, identity.version], ['channel', manifest.channel, identity.channel], ['gitSha', manifest.gitSha, identity.gitSha], diff --git a/apps/widget-cdn/scripts/protocol-contract.test.mjs b/apps/widget-cdn/scripts/protocol-contract.test.mjs new file mode 100644 index 0000000000..93977e94de --- /dev/null +++ b/apps/widget-cdn/scripts/protocol-contract.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { WIDGET_PROTOCOL_MAJOR } from '@layerswap/widget-types'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + +function packageMajor(relativePath) { + const pkg = JSON.parse(readFileSync(join(root, relativePath), 'utf8')); + return Number(pkg.version.split('.')[0]); +} + +test('public loader package majors match the CDN protocol major', () => { + assert.equal(packageMajor('packages/widget/types/package.json'), WIDGET_PROTOCOL_MAJOR); + assert.equal(packageMajor('packages/widget/js/package.json'), WIDGET_PROTOCOL_MAJOR); + assert.equal(packageMajor('packages/widget/react/package.json'), WIDGET_PROTOCOL_MAJOR); +}); + diff --git a/apps/widget-cdn/scripts/release-scripts.test.mjs b/apps/widget-cdn/scripts/release-scripts.test.mjs index b74fc1350c..9dbbbb1474 100644 --- a/apps/widget-cdn/scripts/release-scripts.test.mjs +++ b/apps/widget-cdn/scripts/release-scripts.test.mjs @@ -15,6 +15,7 @@ function deployFixture(t, identity, manifestOverrides = {}) { mkdirSync(dist, { recursive: true }); writeFileSync(join(dist, 'manifest.json'), JSON.stringify({ ...identity, + protocolMajor: 1, remoteEntry: './remoteEntry.js', assetBase: ASSET_BASE, chunks: {}, @@ -137,14 +138,17 @@ test('deploy uploads payloads concurrently and publishes the manifest last', asy assert.equal(completedKeys.at(-1), `${identity.buildId}/manifest.json`); }); -test('rollback updates a channel only for a matching published manifest', async () => { +test('rollback accepts a matching signed legacy channel manifest', async () => { const writes = []; const result = await rollbackChannel({ channel: 'v1', buildId: '1.7.0-0123456789ab', ctx: {}, logger: silentLogger, - readManifest: async () => ({ buildId: '1.7.0-0123456789ab', channel: 'v1' }), + readManifest: async () => ({ + buildId: '1.7.0-0123456789ab', + channel: 'v1', + }), readChannelMap: async () => ({ v1: '1.6.0-aaaaaaaaaaaa' }), writeChannelMap: async (_ctx, channels) => writes.push({ ...channels }), }); @@ -181,6 +185,7 @@ test('rollback rejects an expired target build', async () => { readManifest: async () => ({ buildId: '1.7.0-0123456789ab', channel: 'v1', + protocolMajor: 1, expiresAt: new Date(Date.now() - 1000).toISOString(), }), readChannelMap: async () => ({}), @@ -202,6 +207,7 @@ test('rollback warns but proceeds for a near-expiry target build', async () => { readManifest: async () => ({ buildId: '1.7.0-0123456789ab', channel: 'v1', + protocolMajor: 1, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), }), readChannelMap: async () => ({ v1: '1.6.0-aaaaaaaaaaaa' }), @@ -221,11 +227,15 @@ test('rollback rejects a build from another major channel', async () => { buildId: '2.0.0-0123456789ab', ctx: {}, logger: silentLogger, - readManifest: async () => ({ buildId: '2.0.0-0123456789ab', channel: 'v2' }), + readManifest: async () => ({ + buildId: '2.0.0-0123456789ab', + channel: 'v2', + protocolMajor: 2, + }), readChannelMap: async () => ({}), writeChannelMap: async () => { writes += 1; }, }), - /on v2, not v1/, + /on v2 with protocol 2; expected v1 with protocol 1/, ); assert.equal(writes, 0); }); diff --git a/apps/widget-cdn/scripts/rollback-azure.mjs b/apps/widget-cdn/scripts/rollback-azure.mjs index 135583410e..63461956d3 100644 --- a/apps/widget-cdn/scripts/rollback-azure.mjs +++ b/apps/widget-cdn/scripts/rollback-azure.mjs @@ -11,6 +11,10 @@ import { } from "./azure-lib.mjs"; import { isValidBuildId } from "./build-id.mjs"; import { remoteEntryForBuild } from "./cdn-layout.mjs"; +import { + WIDGET_PROTOCOL_MAJOR, + widgetProtocolMajorOf, +} from "@layerswap/widget-types"; export async function rollbackAzureChannel(options) { const { @@ -34,9 +38,11 @@ export async function rollbackAzureChannel(options) { `(no ${manifestKey} in container).`, ); } + const protocolMajor = widgetProtocolMajorOf(manifest); if ( manifest.buildId !== buildId || manifest.channel !== channel || + protocolMajor !== WIDGET_PROTOCOL_MAJOR || manifest.remoteEntry !== remoteEntryForBuild(buildId) ) { throw new Error( @@ -81,7 +87,7 @@ export async function rollbackAzureChannel(options) { `${current?.buildId ?? "(none)"} → ${buildId}`, ); logger.log( - "[rollback-azure] propagates within ~60s (channel manifest cache).", + "[rollback-azure] channel manifest is no-store; any upstream CDN must honor that header.", ); return { changed: true, previous: current?.buildId }; } diff --git a/apps/widget-cdn/scripts/rollback-r2.mjs b/apps/widget-cdn/scripts/rollback-r2.mjs index 21d4a5f891..22ef52059b 100644 --- a/apps/widget-cdn/scripts/rollback-r2.mjs +++ b/apps/widget-cdn/scripts/rollback-r2.mjs @@ -12,6 +12,7 @@ import { writeChannels, } from './r2-lib.mjs'; import { isValidBuildId } from './build-id.mjs'; +import { WIDGET_PROTOCOL_MAJOR, widgetProtocolMajorOf } from '@layerswap/widget-types'; export async function rollbackChannel(options) { const { @@ -35,10 +36,17 @@ export async function rollbackChannel(options) { `[rollback-r2] build ${buildId} is not published (no ${manifestKey} in bucket).`, ); } - if (manifest.buildId !== buildId || manifest.channel !== channel) { + const protocolMajor = widgetProtocolMajorOf(manifest); + if ( + manifest.buildId !== buildId + || manifest.channel !== channel + || protocolMajor !== WIDGET_PROTOCOL_MAJOR + ) { throw new Error( `[rollback-r2] ${buildId} identifies build ${manifest.buildId ?? '(unknown)'} ` - + `on ${manifest.channel ?? '(unknown)'}, not ${channel}.`, + + `on ${manifest.channel ?? '(unknown)'} with protocol ` + + `${String(protocolMajor)}; expected ${channel} with protocol ` + + `${WIDGET_PROTOCOL_MAJOR}.`, ); } diff --git a/packages/widget/core/src/Models/Theme.ts b/packages/widget/core/src/Models/Theme.ts index 427f1511c6..dc37646211 100644 --- a/packages/widget/core/src/Models/Theme.ts +++ b/packages/widget/core/src/Models/Theme.ts @@ -1,4 +1,4 @@ -// Theme types are defined in the shared, zero-runtime contract package so the +// Theme types are defined in the shared, lightweight contract package so the // widget and the loader packages share one source of truth. Re-exported here // to keep existing `@/Models/Theme` imports working unchanged. export type { ThemeData, ThemeColor, StatusColor } from "@layerswap/widget-types"; diff --git a/packages/widget/core/src/components/Common/Sceletons.tsx b/packages/widget/core/src/components/Common/Sceletons.tsx index 7783a84656..1b6b1e1363 100644 --- a/packages/widget/core/src/components/Common/Sceletons.tsx +++ b/packages/widget/core/src/components/Common/Sceletons.tsx @@ -202,9 +202,9 @@ export const SwapDetailsSceleton = () => { return
-
-
-
+
+
+
diff --git a/packages/widget/js/package.json b/packages/widget/js/package.json index 6e82a0fe99..ecf87a4a65 100644 --- a/packages/widget/js/package.json +++ b/packages/widget/js/package.json @@ -1,6 +1,6 @@ { "name": "@layerswap/widget-js", - "version": "0.1.0", + "version": "1.0.0", "description": "Framework-agnostic loader that fetches the Layerswap widget from the CDN at runtime via Module Federation and mounts it into any DOM element. No framework required.", "type": "module", "main": "dist/esm/index.js", @@ -45,7 +45,7 @@ ], "license": "MIT", "dependencies": { - "@layerswap/widget-types": "workspace:^", + "@layerswap/widget-types": "workspace:*", "@module-federation/runtime": "^0.9.0" }, "engines": { diff --git a/packages/widget/js/src/index.ts b/packages/widget/js/src/index.ts index e9e45c7d14..3d36655f0a 100644 --- a/packages/widget/js/src/index.ts +++ b/packages/widget/js/src/index.ts @@ -1,9 +1,9 @@ // Vanilla, framework-agnostic mount API — the primary entry point. -export { mountWidget } from './mount.js'; -export type { WidgetHandle, MountOptions } from './mount.js'; +export { mountWidget } from "./mount.js"; +export type { WidgetHandle, MountOptions } from "./mount.js"; -// Shared widget prop/config/theme contract, re-exported from the zero-runtime -// `@layerswap/widget-types` package (the single source of truth). +// Shared widget prop/config/theme and protocol contract, re-exported from +// `@layerswap/widget-types` (the single source of truth). export type { WidgetProps, WidgetConfig, @@ -12,14 +12,15 @@ export type { WalletProviderId, ThemeData, ThemeColor, -} from '@layerswap/widget-types'; +} from "@layerswap/widget-types"; +export { WIDGET_PROTOCOL_MAJOR } from "@layerswap/widget-types"; // Lower-level building blocks, reused by `@layerswap/widget-react` and // available for advanced integrators wiring their own loader. -export { resolveSource } from './loader.js'; -export type { ResolvedSource } from './loader.js'; -export { initRemote, loadRemoteModule } from './runtime.js'; -export type { SharedLib } from './runtime.js'; +export { resolveSource } from "./loader.js"; +export type { ResolvedSource } from "./loader.js"; +export { initRemote, loadRemoteModule } from "./runtime.js"; +export type { SharedLib } from "./runtime.js"; // Manifest format + verification primitives. export { @@ -32,8 +33,8 @@ export { canonicalize, fetchManifest, resolveRemoteEntry, -} from './manifest.js'; -export type { Manifest, FetchedManifest } from './manifest.js'; +} from "./manifest.js"; +export type { Manifest, FetchedManifest } from "./manifest.js"; // Runtime SRI interceptor. -export { registerChunkHashes } from './sri.js'; +export { registerChunkHashes } from "./sri.js"; diff --git a/packages/widget/js/src/loader.ts b/packages/widget/js/src/loader.ts index 14c7ce839b..83b6796e00 100644 --- a/packages/widget/js/src/loader.ts +++ b/packages/widget/js/src/loader.ts @@ -1,5 +1,6 @@ import { fetchManifest, resolveRemoteEntry, verifyManifest, manifestFreshness, ManifestError, DEFAULT_MANIFEST_URL } from './manifest.js'; import { registerChunkHashes } from './sri.js'; +import { WIDGET_PROTOCOL_MAJOR, widgetProtocolMajorOf } from '@layerswap/widget-types'; export type ResolvedSource = { remoteEntry: string }; @@ -101,6 +102,13 @@ async function resolveSourceOnce(manifestUrl: string, verify: boolean): Promise< ); } } + const remoteProtocolMajor = widgetProtocolMajorOf(manifest); + if (remoteProtocolMajor !== WIDGET_PROTOCOL_MAJOR) { + throw new ManifestError( + 'incompatible', + `widget protocol v${String(remoteProtocolMajor)} is incompatible with loader v${WIDGET_PROTOCOL_MAJOR}`, + ); + } // Identify the build in the console — version, commit, and build time from // the (now-validated) manifest. Answers "which build is this page actually // running?" without leaving the browser, e.g. when a page looks stale after diff --git a/packages/widget/js/src/manifest.ts b/packages/widget/js/src/manifest.ts index d84f687dce..7e6e102f9a 100644 --- a/packages/widget/js/src/manifest.ts +++ b/packages/widget/js/src/manifest.ts @@ -1,3 +1,5 @@ +import { WIDGET_PROTOCOL_MAJOR } from '@layerswap/widget-types'; + /** * Manifest format published at `//manifest.json` by the CDN. * @@ -11,12 +13,12 @@ * of the manifest with `signature` itself set to `null`. See `verifyManifest`. */ export type Manifest = { - /** Semver of the build this manifest describes (the `@layerswap/widget` version). */ + /** Loader/remote compatibility boundary. Must match WIDGET_PROTOCOL_MAJOR. */ + protocolMajor: number; + /** Implementation semver of the `@layerswap/widget` package in this build. */ version: string; /** - * Major channel this build belongs to, e.g. `"v1"`. Informational — the - * loader fetches whatever manifest URL it's given; this field lets tooling - * and humans see which compatibility channel a pinned build came from. + * Major protocol channel this build belongs to, e.g. `"v1"`. */ channel?: string; /** @@ -90,9 +92,8 @@ export type Manifest = { * Rotating this key requires a version bump of `@layerswap/widget-js`; * integrators pin it transitively via npm SRI. * - * Current key: generated 2026-06 (pre-KMS). Before a 1.0 release, regenerate - * in a KMS/HSM and update this constant plus the GitHub secret - * `LAYERSWAP_PRIVATE_KEY_PEM`. + * Current key: generated 2026-06 (pre-KMS). A future KMS/HSM migration must + * update this constant plus the GitHub secret `LAYERSWAP_PRIVATE_KEY_PEM`. */ export const MANIFEST_VERIFY_PUBLIC_KEY_SPKI_B64 = 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESuHFHbltz/hfcY+DzIrLq7Ixc4efHE8SLZdNg0pZZDHTfdwbqLpGk4461EgNranHLWnVsoAbyQ4IyHIVnRAVKw=='; @@ -109,14 +110,13 @@ export const MANIFEST_VERIFY_PUBLIC_KEY_SPKI_B64 = * manifest.json` and publish a new `@layerswap/widget-js` — integrators pick * up the new origin transitively via npm. * - * The major (`/v1/`) is pinned to this package's major version: when Layerswap - * cuts a breaking `/v2/`, it ships a new loader major whose default points - * there. There is no per-call override — pinning an exact build means - * installing an older package version. (Layerswap's own dev harnesses can - * repoint the loader at a local server via the internal `__LAYERSWAP_WIDGET_*` - * globals — see `resolveSource` in `loader.ts`.) + * The channel major is the shared widget protocol major. A breaking `/v2/` + * ships with loader package major 2. Exact build pinning is intentionally not + * a public loader feature. Layerswap's own dev harnesses can repoint the + * loader via the internal globals described in `loader.ts`. */ -export const DEFAULT_MANIFEST_URL = 'https://layerswap-widget-cdn.layerswapcdn.workers.dev/v1/manifest.json'; +export const DEFAULT_MANIFEST_URL = + `https://layerswap-widget-cdn.layerswapcdn.workers.dev/v${WIDGET_PROTOCOL_MAJOR}/manifest.json`; const fromB64 = (b64: string): ArrayBuffer => { const bin = atob(b64); @@ -214,7 +214,10 @@ export function resolveRemoteEntry(manifestUrl: string, remoteEntry: string): st } export class ManifestError extends Error { - constructor(public readonly reason: 'fetch' | 'parse' | 'signature' | 'kill-switch' | 'stale', message: string) { + constructor( + public readonly reason: 'fetch' | 'parse' | 'signature' | 'kill-switch' | 'stale' | 'incompatible', + message: string, + ) { super(message); this.name = 'ManifestError'; } diff --git a/packages/widget/js/src/types.ts b/packages/widget/js/src/types.ts index b52e9b442a..223cb4a87d 100644 --- a/packages/widget/js/src/types.ts +++ b/packages/widget/js/src/types.ts @@ -1,4 +1,4 @@ -// The widget prop/config/callback contract lives in the shared, zero-runtime +// The widget prop/config/callback contract lives in the shared, lightweight // `@layerswap/widget-types` package so the widget and every loader share one // source of truth. Re-exported here for local (`./types`) imports. export type { @@ -7,4 +7,4 @@ export type { WidgetCallbacks, WalletDefaults, WalletProviderId, -} from '@layerswap/widget-types'; +} from "@layerswap/widget-types"; diff --git a/packages/widget/js/tests/loader-assets.test.mjs b/packages/widget/js/tests/loader-assets.test.mjs index 68383283a5..a141c36b20 100644 --- a/packages/widget/js/tests/loader-assets.test.mjs +++ b/packages/widget/js/tests/loader-assets.test.mjs @@ -33,6 +33,7 @@ globalThis.fetch = async () => ({ ok: true, url: 'https://cdn.example/1.7.0-0123456789ab/manifest.json', json: async () => ({ + protocolMajor: 1, version: '1.7.0', remoteEntry: './remoteEntry.js', assetBase: '../assets/', diff --git a/packages/widget/js/tests/loader-singleflight.test.mjs b/packages/widget/js/tests/loader-singleflight.test.mjs index a56f5d8a7d..d2accc18c8 100644 --- a/packages/widget/js/tests/loader-singleflight.test.mjs +++ b/packages/widget/js/tests/loader-singleflight.test.mjs @@ -17,6 +17,7 @@ globalThis.fetch = async () => { ok: true, url: 'https://cdn.example/1.7.0-0123456789ab/manifest.json', json: async () => ({ + protocolMajor: 1, version: '1.7.0', remoteEntry: './remoteEntry.js', }), diff --git a/packages/widget/js/tests/protocol.test.mjs b/packages/widget/js/tests/protocol.test.mjs new file mode 100644 index 0000000000..b38a93cb32 --- /dev/null +++ b/packages/widget/js/tests/protocol.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +globalThis.window = { location: { href: 'https://host.example/page' } }; +globalThis.__LAYERSWAP_WIDGET_MANIFEST__ = 'https://cdn.example/v1/manifest.json'; +globalThis.__LAYERSWAP_WIDGET_VERIFY__ = false; + +const { WIDGET_PROTOCOL_MAJOR } = await import('../dist/esm/index.js'); + +test('the loader package major matches the supported widget protocol', async () => { + const { readFile } = await import('node:fs/promises'); + const loaderPackage = JSON.parse( + await readFile(new URL('../package.json', import.meta.url), 'utf8'), + ); + assert.equal(Number(loaderPackage.version.split('.')[0]), WIDGET_PROTOCOL_MAJOR); +}); + +test('rejects a remote from another protocol major', async () => { + globalThis.fetch = async () => ({ + ok: true, + url: 'https://cdn.example/2.0.0-0123456789ab/manifest.json', + json: async () => ({ + protocolMajor: WIDGET_PROTOCOL_MAJOR + 1, + version: '2.0.0', + remoteEntry: './remoteEntry.js', + }), + }); + + const { resolveSource } = await import('../dist/esm/loader.js'); + await assert.rejects( + () => resolveSource(), + (error) => error?.name === 'ManifestError' && error?.reason === 'incompatible', + ); +}); + +test('accepts a signed legacy manifest whose channel identifies protocol v1', async () => { + globalThis.fetch = async () => ({ + ok: true, + url: 'https://cdn.example/1.7.0-legacy/manifest.json', + json: async () => ({ + channel: 'v1', + version: '1.7.0', + remoteEntry: './remoteEntry.js', + }), + }); + + const { resolveSource } = await import('../dist/esm/loader.js'); + const source = await resolveSource(); + assert.equal(source.remoteEntry, 'https://cdn.example/1.7.0-legacy/remoteEntry.js'); +}); diff --git a/packages/widget/react/README.md b/packages/widget/react/README.md index d9e99c96a8..4634c1b800 100644 --- a/packages/widget/react/README.md +++ b/packages/widget/react/README.md @@ -46,9 +46,15 @@ Works in the Next.js App Router out of the box: the component declares only starts the browser-only loader after hydration — no `next/dynamic` wrapper needed. -To ride a different major channel (e.g. a future `/v2/`), upgrade the -`@layerswap/widget-react` package — the source URL is pinned to the package -version, not passed at runtime. +The npm package major selects the CDN protocol major: + +```text +@layerswap/widget-react@1.x → /v1/manifest.json +@layerswap/widget-react@2.x → /v2/manifest.json +``` + +Minor and patch widget builds roll forward within that major without an +integrator redeploy. Exact CDN builds are not a public pinning API. ## Reusing the host's wagmi config @@ -176,6 +182,7 @@ Notes: | Widget never mounts | `ManifestError('kill-switch')` | Operational kill-switch set on the manifest. | | Widget never mounts | `ManifestError('signature')` | Manifest has no/invalid signature (verification is always on). | | Widget never mounts | `ManifestError('stale')` | Manifest expired (or carries no validity window) — replay protection refuses possibly-rolled-back builds. Layerswap re-publishing the channel resolves it. | +| Widget never mounts | `ManifestError('incompatible')` | Manifest protocol major does not match this loader package major. | | Widget loads but errors at render | Component-level | Catch via `callbacks.onError`. | ## Local development diff --git a/packages/widget/react/package.json b/packages/widget/react/package.json index 989b4da811..58a3e55a23 100644 --- a/packages/widget/react/package.json +++ b/packages/widget/react/package.json @@ -1,6 +1,6 @@ { "name": "@layerswap/widget-react", - "version": "0.1.0", + "version": "1.0.0", "description": "React component that fetches the Layerswap widget from the CDN at runtime via Module Federation. Integrators install this and nothing else.", "type": "module", "main": "dist/esm/index.js", @@ -49,7 +49,7 @@ "wagmi": "catalog:" }, "dependencies": { - "@layerswap/widget-js": "workspace:^" + "@layerswap/widget-js": "workspace:*" }, "devDependencies": { "@types/react": "catalog:", diff --git a/packages/widget/react/src/index.ts b/packages/widget/react/src/index.ts index 4052108e7f..75d2e977f2 100644 --- a/packages/widget/react/src/index.ts +++ b/packages/widget/react/src/index.ts @@ -5,8 +5,8 @@ export type { Manifest } from '@layerswap/widget-js'; export { ManifestError } from '@layerswap/widget-js'; // Widget prop/config/theme contract, re-exported (via the core) from the -// shared zero-runtime `@layerswap/widget-types` package — no dependency on -// the heavy `@layerswap/widget` package. The shared contract is +// shared `@layerswap/widget-types` package — no dependency on the heavy +// `@layerswap/widget` package. The shared contract is // framework-agnostic (its React/wagmi slots are generic); this package binds // them to its own peers so React hosts get concrete types. import type { ReactNode } from 'react'; diff --git a/packages/widget/types/package.json b/packages/widget/types/package.json index 9d5b8bd7d4..de25673213 100644 --- a/packages/widget/types/package.json +++ b/packages/widget/types/package.json @@ -1,7 +1,7 @@ { "name": "@layerswap/widget-types", - "version": "0.1.0", - "description": "Shared, zero-runtime TypeScript contract for the Layerswap widget: config, callbacks, theme, and prop types consumed by @layerswap/widget, @layerswap/widget-js, and @layerswap/widget-react.", + "version": "1.0.0", + "description": "Shared TypeScript and protocol contract for the Layerswap widget, loaders, and CDN remote.", "type": "module", "main": "dist/esm/index.js", "types": "dist/types/index.d.ts", diff --git a/packages/widget/types/src/config.ts b/packages/widget/types/src/config.ts index eec457e94d..77fdcff7f0 100644 --- a/packages/widget/types/src/config.ts +++ b/packages/widget/types/src/config.ts @@ -1,4 +1,4 @@ -import type { ThemeData } from './theme'; +import type { ThemeData } from "./theme"; /** * Wallet provider ids matching what the remote's `getDefaultProviders()` @@ -8,15 +8,15 @@ import type { ThemeData } from './theme'; * Note the id for Solana is `'solana'` (the chain), not `'svm'`. */ export type WalletProviderId = - | 'evm' - | 'starknet' - | 'fuel' - | 'paradex' - | 'bitcoin' - | 'ton' - | 'solana' - | 'tron' - | 'imtblPassport'; + | "evm" + | "starknet" + | "fuel" + | "paradex" + | "bitcoin" + | "ton" + | "solana" + | "tron" + | "imtblPassport"; /** * Public widget configuration contract. @@ -25,7 +25,7 @@ export type WalletProviderId = * (`@layerswap/widget`) refines it internally — its `LayerswapWidgetConfig` * is `WidgetConfig` intersected with precise types for the deep fields * (`settings`, `initialValues`) — so the two can never structurally diverge, - * while integrators and the loaders depend only on this zero-runtime package. + * while integrators and the loaders depend only on this lightweight package. * * Framework-agnostic by construction: `TLoading` is the host's renderable type * (`ReactNode` in React hosts — `@layerswap/widget-react` binds it), kept open @@ -36,7 +36,7 @@ export type WidgetConfig = { apiKey?: string; apiUri?: string; /** Network set to target. */ - version?: 'mainnet' | 'testnet'; + version?: "mainnet" | "testnet"; /** Visual theme overrides. */ theme?: ThemeData | null; /** diff --git a/packages/widget/types/src/index.ts b/packages/widget/types/src/index.ts index 28afd740f2..b022efed2e 100644 --- a/packages/widget/types/src/index.ts +++ b/packages/widget/types/src/index.ts @@ -1,4 +1,5 @@ export type { ThemeData, ThemeColor, StatusColor } from './theme'; +export { WIDGET_PROTOCOL_MAJOR, widgetProtocolMajorOf } from './protocol.js'; export type { WidgetConfig, WidgetCallbacks, diff --git a/packages/widget/types/src/protocol.ts b/packages/widget/types/src/protocol.ts new file mode 100644 index 0000000000..9a73199324 --- /dev/null +++ b/packages/widget/types/src/protocol.ts @@ -0,0 +1,29 @@ +/** + * Public compatibility boundary between an npm loader and the CDN remote. + * + * This changes only when the loader/remote contract breaks (manifest shape, + * exposed Module Federation modules, widget props, or required host runtime). + * It is deliberately independent from @layerswap/widget's implementation + * version so ordinary core releases continue to roll within the same channel. + */ +export const WIDGET_PROTOCOL_MAJOR = 1; + +/** + * Read a manifest's compatibility major. The channel fallback keeps signed + * pre-protocol-field v1 manifests usable during the migration; every newly + * published manifest is required to carry protocolMajor explicitly. + */ +export function widgetProtocolMajorOf(manifest: { + protocolMajor?: unknown; + channel?: unknown; +}): number | undefined { + if ( + Number.isInteger(manifest.protocolMajor) && + Number(manifest.protocolMajor) > 0 + ) { + return Number(manifest.protocolMajor); + } + if (typeof manifest.channel !== "string") return undefined; + const match = /^v([1-9]\d*)$/.exec(manifest.channel); + return match ? Number(match[1]) : undefined; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5f4721338..6d5aa58a10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -958,7 +958,7 @@ importers: specifier: workspace:^ version: link:../../utils '@layerswap/widget-types': - specifier: workspace:^ + specifier: workspace:* version: link:../types '@number-flow/react': specifier: ^0.5.10 @@ -1100,7 +1100,7 @@ importers: packages/widget/react: dependencies: '@layerswap/widget-js': - specifier: workspace:^ + specifier: workspace:* version: link:../js devDependencies: '@types/react': From e36b711192f3db6a28359fd4fccb2e0c2530493b Mon Sep 17 00:00:00 2001 From: babkenmes Date: Mon, 3 Aug 2026 20:31:14 +0400 Subject: [PATCH 03/24] fix: adjust workspace specifiers for widget types in pnpm-lock.yaml --- pnpm-lock.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b09c6bfae..649bacbaaf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -946,7 +946,7 @@ importers: specifier: workspace:^ version: link:../../utils '@layerswap/widget-types': - specifier: workspace:* + specifier: workspace:^ version: link:../types '@number-flow/react': specifier: ^0.5.10 @@ -1079,7 +1079,7 @@ importers: packages/widget/js: dependencies: '@layerswap/widget-types': - specifier: workspace:^ + specifier: workspace:* version: link:../types '@module-federation/runtime': specifier: ^0.9.0 From 530a8450604fb12bd9ac15cb3a5dd5d3c59445d3 Mon Sep 17 00:00:00 2001 From: babkenmes Date: Mon, 3 Aug 2026 20:56:30 +0400 Subject: [PATCH 04/24] fix: update ignoreCommand to include pnpm-lock.yaml and pnpm-workspace.yaml --- examples/widget-react-host/vercel.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/widget-react-host/vercel.json b/examples/widget-react-host/vercel.json index 4636a69832..a9aca1f852 100644 --- a/examples/widget-react-host/vercel.json +++ b/examples/widget-react-host/vercel.json @@ -9,5 +9,5 @@ "main": true } }, - "ignoreCommand": "bash -c 'cd ../.. && git diff --quiet HEAD^ HEAD -- examples/widget-react-host packages || exit 1'" + "ignoreCommand": "bash -c 'cd ../.. && git diff --quiet HEAD^ HEAD -- examples/widget-react-host packages pnpm-lock.yaml pnpm-workspace.yaml || exit 1'" } From 638b083ad9f70b857b6d77261d8df40370234aaf Mon Sep 17 00:00:00 2001 From: babkenmes Date: Mon, 3 Aug 2026 20:58:33 +0400 Subject: [PATCH 05/24] fix: update ignoreCommand in vercel.json to exclude pnpm lock files --- examples/widget-react-host/package.json | 3 ++- examples/widget-react-host/vercel.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/widget-react-host/package.json b/examples/widget-react-host/package.json index fb580e0789..3444efe322 100644 --- a/examples/widget-react-host/package.json +++ b/examples/widget-react-host/package.json @@ -17,6 +17,7 @@ "wagmi": "catalog:", "zustand": "catalog:" }, + "packageManager": "pnpm@10.20.0", "devDependencies": { "@types/react": "catalog:", "@types/react-dom": "catalog:", @@ -25,4 +26,4 @@ "vite": "^7.3.0", "vite-plugin-node-polyfills": "^0.24.0" } -} +} \ No newline at end of file diff --git a/examples/widget-react-host/vercel.json b/examples/widget-react-host/vercel.json index a9aca1f852..4636a69832 100644 --- a/examples/widget-react-host/vercel.json +++ b/examples/widget-react-host/vercel.json @@ -9,5 +9,5 @@ "main": true } }, - "ignoreCommand": "bash -c 'cd ../.. && git diff --quiet HEAD^ HEAD -- examples/widget-react-host packages pnpm-lock.yaml pnpm-workspace.yaml || exit 1'" + "ignoreCommand": "bash -c 'cd ../.. && git diff --quiet HEAD^ HEAD -- examples/widget-react-host packages || exit 1'" } From bc07a50bdcdbee529de2faa1912b21898e7ab7eb Mon Sep 17 00:00:00 2001 From: babkenmes Date: Tue, 4 Aug 2026 14:26:06 +0400 Subject: [PATCH 06/24] fix: update widget manifest handling and verification logic across components --- apps/widget-cdn/AZURE_SETUP.md | 16 ++-- apps/widget-cdn/README.md | 21 ++--- examples/widget-react-host/README.md | 23 +---- examples/widget-react-host/src/App.tsx | 29 +------ packages/widget/js/src/index.ts | 2 +- packages/widget/js/src/loader.ts | 87 ++++++------------- packages/widget/js/src/manifest.ts | 7 +- .../widget/js/tests/loader-assets.test.mjs | 13 ++- .../js/tests/loader-singleflight.test.mjs | 43 ++++++--- .../js/tests/loader-verification.test.mjs | 34 ++++++++ packages/widget/js/tests/protocol.test.mjs | 15 +++- packages/widget/react/README.md | 24 ++--- 12 files changed, 142 insertions(+), 172 deletions(-) create mode 100644 packages/widget/js/tests/loader-verification.test.mjs diff --git a/apps/widget-cdn/AZURE_SETUP.md b/apps/widget-cdn/AZURE_SETUP.md index 7c91b74700..74f8296f17 100644 --- a/apps/widget-cdn/AZURE_SETUP.md +++ b/apps/widget-cdn/AZURE_SETUP.md @@ -174,17 +174,11 @@ node -e " " ``` -Then run the internal React host against Azure with signature verification -disabled for this temporary key: - -```bash -VITE_LAYERSWAP_MANIFEST="$WIDGET_AZURE_BASE/v1/manifest.json" \ -VITE_LAYERSWAP_VERIFY=false \ -pnpm --filter widget-react-host-example dev -- \ - --host 127.0.0.1 --port 3001 --no-open -``` - -Open `http://127.0.0.1:3001`. +The published React/JS loaders cannot be repointed at this sandbox: their +manifest URL and signature verification policy are fixed inside +`@layerswap/widget-js`. Browser integration testing requires a loader build +whose internal `WIDGET_MANIFEST_URL` and trust anchor target the sandbox; do not add +host environment variables or globals to bypass that boundary. ## 10. GitHub Actions branch sandbox diff --git a/apps/widget-cdn/README.md b/apps/widget-cdn/README.md index 2414643514..9c475e7234 100644 --- a/apps/widget-cdn/README.md +++ b/apps/widget-cdn/README.md @@ -42,12 +42,11 @@ The Worker serves two kinds of URL: Integrators don't choose between them: the manifest URL is not a public knob. `@layerswap/widget-js` bakes in the rolling channel URL -(`DEFAULT_MANIFEST_URL` in `src/manifest.ts`) and `resolveSource()` takes no +(`WIDGET_MANIFEST_URL` in `src/manifest.ts`) and `resolveSource()` takes no arguments, so every integrator rides the channel and picks up pointer flips automatically. Immutable URLs exist for Layerswap's own release mechanics -(staged releases, rollback targets, debugging a specific build) and are only -reachable from a loader via the internal `__LAYERSWAP_WIDGET_MANIFEST__` -override global — a build/test seam, not a supported integrator option. +(staged releases, rollback targets, and debugging a specific build); hosts +cannot point the loader at them. The manifest uses a build-addressed relative `remoteEntry` (for example `../1.5.0-abc123def456/remoteEntry.js`), so the remote anchors at the immutable @@ -74,14 +73,9 @@ pnpm dev ``` Serves the remote on `http://127.0.0.1:3100/remoteEntry.js`, plus an unsigned -`http://127.0.0.1:3100/manifest.json` pointing at it — so the loader's manifest -path works in dev exactly as in prod. Point a dev host at it via the internal -loader globals (set before the widget mounts; see `examples/widget-react-host`): - -```js -globalThis.__LAYERSWAP_WIDGET_MANIFEST__ = 'http://127.0.0.1:3100/manifest.json'; -globalThis.__LAYERSWAP_WIDGET_VERIFY__ = false; // dev manifest is unsigned -``` +`http://127.0.0.1:3100/manifest.json` pointing at it. The published loader is +intentionally fixed to the signed production channel and cannot load this +unsigned development manifest. Dev output stays flat in `dist/` (no version directory, no redirect). @@ -99,8 +93,7 @@ verifying loaders refuse a manifest that is expired or missing `expiresAt`), the shared `assetBase`, per-chunk SHA-384 SRI hashes, the kill switch, and the signature. Without `LAYERSWAP_PRIVATE_KEY_PEM` the manifest is emitted unsigned — fine for local builds, rejected by the deploy script and by the -loader (integrators always verify; only the internal -`__LAYERSWAP_WIDGET_VERIFY__ = false` dev seam accepts unsigned manifests). +loader (integrators always verify). `LAYERSWAP_RELEASE_VERSION` overrides the version label and `LAYERSWAP_RELEASE_ID` the buildId (and therefore the output directory) for a diff --git a/examples/widget-react-host/README.md b/examples/widget-react-host/README.md index 0427d032ac..7b9743e81d 100644 --- a/examples/widget-react-host/README.md +++ b/examples/widget-react-host/README.md @@ -7,23 +7,14 @@ wallet adapters, no `framer-motion`, etc.). ## Run locally -Two dev servers — the CDN-remote and this host app. - ```bash -# Terminal 1 — serve the federated remote on :3100 -pnpm --filter @layerswap/widget-cdn dev - -# Terminal 2 — serve this example on :3001 pnpm --filter widget-react-host-example dev -- --host 127.0.0.1 --port 3001 --no-open ``` -Then open `http://127.0.0.1:3001/`. The host page fetches the manifest at -`http://127.0.0.1:3100/manifest.json` from the local CDN dev-server, then -loads the `remoteEntry.js` it points at and mounts the widget. - -Point at a production CDN by setting `VITE_LAYERSWAP_MANIFEST` (e.g. -`https://cdn.layerswap.io/v1/manifest.json`), and `VITE_LAYERSWAP_VERIFY=true` -to require a valid manifest signature. +Then open `http://127.0.0.1:3001/`. The loader fetches and verifies the manifest +from its built-in production channel, then loads the `remoteEntry.js` it points +at and mounts the widget. The host cannot replace the manifest URL or disable +verification. ## What's in the host bundle @@ -31,9 +22,3 @@ Only `@layerswap/widget-react` (a few KB plus `@module-federation/runtime`). React, react-dom, wagmi, viem, react-query, and zustand stay as the host's own copies (declared peer-deps on `@layerswap/widget-react`) and are fed into the MF shared scope so the remote uses those exact instances. - -## Known follow-ups (not blockers) - -- The local dev-server manifest is unsigned, so this example runs with - `verify` off by default. Point it at a signed prod build and set - `VITE_LAYERSWAP_VERIFY=true` to exercise signature verification. diff --git a/examples/widget-react-host/src/App.tsx b/examples/widget-react-host/src/App.tsx index c3d43b4d96..d032fe6e12 100644 --- a/examples/widget-react-host/src/App.tsx +++ b/examples/widget-react-host/src/App.tsx @@ -4,31 +4,6 @@ import { LayerswapWidget } from '@layerswap/widget-react'; import { wagmiConfig } from './wagmi'; import { HostWallet } from './HostWallet'; -// Integrators do NOT configure the widget's source — it is always the -// canonical signed Layerswap CDN baked into `@layerswap/widget-js`. This dev -// harness is the exception: it points the loader at the local widget-cdn dev -// server (`pnpm dev` in apps/widget-cdn) via the internal `__LAYERSWAP_WIDGET_*` -// override globals. These are an undocumented build/test seam, not a public API. -// -// Defaults to the local dev server; set VITE_LAYERSWAP_MANIFEST to a production -// URL (e.g. https://cdn.layerswap.io/v1/manifest.json). The dev server emits an -// unsigned manifest, so verification is off unless VITE_LAYERSWAP_VERIFY=true. -const MANIFEST_URL = - import.meta.env.VITE_LAYERSWAP_MANIFEST ?? 'http://127.0.0.1:3100/manifest.json'; -const VERIFY = import.meta.env.VITE_LAYERSWAP_VERIFY === 'true'; - -declare global { - interface Window { - __LAYERSWAP_WIDGET_MANIFEST__?: string; - __LAYERSWAP_WIDGET_VERIFY__?: boolean; - } -} - -// Set before mounts (module scope runs first), so the loader -// reads them when it resolves the source. -window.__LAYERSWAP_WIDGET_MANIFEST__ = MANIFEST_URL; -window.__LAYERSWAP_WIDGET_VERIFY__ = VERIFY; - const queryClient = new QueryClient(); export function App() { @@ -50,8 +25,8 @@ export function App() {

The host page below mounts its own WagmiProvider and a - connect button. The Layerswap widget is fetched at runtime via the - manifest at {MANIFEST_URL}. Connect the host wallet and + connect button. The Layerswap widget is fetched from the signed CDN + configured inside the loader package. Connect the host wallet and compare its account against what the widget sees.

diff --git a/packages/widget/js/src/index.ts b/packages/widget/js/src/index.ts index 3d36655f0a..ebef13d4f8 100644 --- a/packages/widget/js/src/index.ts +++ b/packages/widget/js/src/index.ts @@ -27,7 +27,7 @@ export { ManifestError, MANIFEST_VERIFY_PUBLIC_KEY_SPKI_B64, MANIFEST_CLOCK_SKEW_MS, - DEFAULT_MANIFEST_URL, + WIDGET_MANIFEST_URL, verifyManifest, manifestFreshness, canonicalize, diff --git a/packages/widget/js/src/loader.ts b/packages/widget/js/src/loader.ts index 83b6796e00..4a5bdfeeff 100644 --- a/packages/widget/js/src/loader.ts +++ b/packages/widget/js/src/loader.ts @@ -1,45 +1,17 @@ -import { fetchManifest, resolveRemoteEntry, verifyManifest, manifestFreshness, ManifestError, DEFAULT_MANIFEST_URL } from './manifest.js'; +import { fetchManifest, resolveRemoteEntry, verifyManifest, manifestFreshness, ManifestError, WIDGET_MANIFEST_URL } from './manifest.js'; import { registerChunkHashes } from './sri.js'; import { WIDGET_PROTOCOL_MAJOR, widgetProtocolMajorOf } from '@layerswap/widget-types'; export type ResolvedSource = { remoteEntry: string }; -/** - * Internal-only override, read from `globalThis`. NOT part of the public API: - * integrators always get the canonical signed CDN baked into this package - * ({@link DEFAULT_MANIFEST_URL}) and cannot repoint the loader. Layerswap's own - * dev harnesses (the example host, the playground) set these globals before the - * widget mounts to target the local unsigned dev server. Undocumented on - * purpose — treat it as a build/test seam, not a supported knob. - * - * globalThis.__LAYERSWAP_WIDGET_MANIFEST__ = 'http://127.0.0.1:3100/manifest.json'; - * globalThis.__LAYERSWAP_WIDGET_VERIFY__ = false; - */ -type InternalOverrideGlobals = { - __LAYERSWAP_WIDGET_MANIFEST__?: unknown; - __LAYERSWAP_WIDGET_VERIFY__?: unknown; -}; - -function resolveConfig(): { manifestUrl: string; verify: boolean } { - const g = globalThis as InternalOverrideGlobals; - const manifestUrl = - typeof g.__LAYERSWAP_WIDGET_MANIFEST__ === 'string' && g.__LAYERSWAP_WIDGET_MANIFEST__ - ? g.__LAYERSWAP_WIDGET_MANIFEST__ - : DEFAULT_MANIFEST_URL; - // Fail closed: verification is on unless a harness explicitly disables it. - const verify = typeof g.__LAYERSWAP_WIDGET_VERIFY__ === 'boolean' ? g.__LAYERSWAP_WIDGET_VERIFY__ : true; - return { manifestUrl, verify }; -} - /** * Fetch + validate the manifest and install per-chunk SRI, returning the * resolved remoteEntry URL. Framework-agnostic — shared by the vanilla * `mountWidget` and the React `LayerswapWidget` so the security-critical path * (signature check + SRI registration) lives in exactly one place. * - * Takes no arguments: the manifest URL is the canonical Layerswap CDN baked - * into this package. (Layerswap's own dev harnesses can repoint it via the - * internal `__LAYERSWAP_WIDGET_*` globals — see {@link resolveConfig}.) + * Takes no arguments: the manifest URL and verification policy are fixed in + * this package. Hosts cannot repoint the loader or disable verification. * * Single-flight: concurrent mounts share one fetch + signature verification + * SRI registration. A successful resolution is reused for a short window @@ -48,59 +20,54 @@ function resolveConfig(): { manifestUrl: string; verify: boolean } { * Failures are never cached. */ export function resolveSource(): Promise { - const { manifestUrl, verify } = resolveConfig(); - const key = `${verify ? 'v' : 'u'}:${manifestUrl}`; const now = Date.now(); - const cached = pendingResolves.get(key); + const cached = pendingResolve; if (cached && (cached.settledAt === undefined || now - cached.settledAt < RESOLVE_REUSE_MS)) { return cached.promise; } const entry: PendingResolve = { - promise: resolveSourceOnce(manifestUrl, verify).then( + promise: resolveSourceOnce().then( (result) => { entry.settledAt = Date.now(); return result; }, (error) => { - pendingResolves.delete(key); + if (pendingResolve === entry) pendingResolve = undefined; throw error; }, ), }; - pendingResolves.set(key, entry); + pendingResolve = entry; return entry.promise; } const RESOLVE_REUSE_MS = 60_000; type PendingResolve = { promise: Promise; settledAt?: number }; -const pendingResolves = new Map(); +let pendingResolve: PendingResolve | undefined; -async function resolveSourceOnce(manifestUrl: string, verify: boolean): Promise { - // When verifying, force a revalidation so we check the freshest bytes. - // Otherwise let the browser HTTP cache satisfy repeated mounts. - const { manifest, url: resolvedManifestUrl } = await fetchManifest(manifestUrl, !verify); +async function resolveSourceOnce(): Promise { + // Force a revalidation so we always verify the freshest manifest bytes. + const { manifest, url: resolvedManifestUrl } = await fetchManifest(WIDGET_MANIFEST_URL); if (manifest.killSwitch) { throw new ManifestError('kill-switch', 'manifest kill switch is set — refusing to load remote'); } - if (verify) { - const ok = await verifyManifest(manifest); - if (!ok) { - throw new ManifestError('signature', 'manifest signature is missing or invalid'); - } - // Freshness is only meaningful once the signed body is trusted (an - // attacker controls unverified fields anyway) — and it is REQUIRED then: - // a valid-but-stale manifest is exactly the replay this check exists to - // stop. See `Manifest.expiresAt` for the availability policy. - const freshness = manifestFreshness(manifest, Date.now()); - if (freshness !== 'fresh') { - throw new ManifestError( - 'stale', - freshness === 'expired' - ? `manifest expired at ${manifest.expiresAt} — refusing a possibly replayed build` - : 'manifest carries no valid expiresAt — refusing to trust it indefinitely', - ); - } + const ok = await verifyManifest(manifest); + if (!ok) { + throw new ManifestError('signature', 'manifest signature is missing or invalid'); + } + // Freshness is only meaningful once the signed body is trusted (an + // attacker controls unverified fields anyway) — and it is REQUIRED then: + // a valid-but-stale manifest is exactly the replay this check exists to + // stop. See `Manifest.expiresAt` for the availability policy. + const freshness = manifestFreshness(manifest, Date.now()); + if (freshness !== 'fresh') { + throw new ManifestError( + 'stale', + freshness === 'expired' + ? `manifest expired at ${manifest.expiresAt} — refusing a possibly replayed build` + : 'manifest carries no valid expiresAt — refusing to trust it indefinitely', + ); } const remoteProtocolMajor = widgetProtocolMajorOf(manifest); if (remoteProtocolMajor !== WIDGET_PROTOCOL_MAJOR) { diff --git a/packages/widget/js/src/manifest.ts b/packages/widget/js/src/manifest.ts index 7e6e102f9a..5fa6694de6 100644 --- a/packages/widget/js/src/manifest.ts +++ b/packages/widget/js/src/manifest.ts @@ -112,11 +112,10 @@ export const MANIFEST_VERIFY_PUBLIC_KEY_SPKI_B64 = * * The channel major is the shared widget protocol major. A breaking `/v2/` * ships with loader package major 2. Exact build pinning is intentionally not - * a public loader feature. Layerswap's own dev harnesses can repoint the - * loader via the internal globals described in `loader.ts`. + * a public loader feature. */ -export const DEFAULT_MANIFEST_URL = - `https://layerswap-widget-cdn.layerswapcdn.workers.dev/v${WIDGET_PROTOCOL_MAJOR}/manifest.json`; +export const WIDGET_MANIFEST_URL = + `https://layerswapcdntest.blob.core.windows.net/widget-cdn/v${WIDGET_PROTOCOL_MAJOR}/manifest.json`; const fromB64 = (b64: string): ArrayBuffer => { const bin = atob(b64); diff --git a/packages/widget/js/tests/loader-assets.test.mjs b/packages/widget/js/tests/loader-assets.test.mjs index a141c36b20..8317ff2b2c 100644 --- a/packages/widget/js/tests/loader-assets.test.mjs +++ b/packages/widget/js/tests/loader-assets.test.mjs @@ -24,8 +24,15 @@ class FakeScriptElement { globalThis.window = { location: { href: 'https://host.example/page' } }; globalThis.HTMLScriptElement = FakeScriptElement; -globalThis.__LAYERSWAP_WIDGET_MANIFEST__ = 'https://cdn.example/v1/manifest.json'; -globalThis.__LAYERSWAP_WIDGET_VERIFY__ = false; +Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { + subtle: { + importKey: async () => ({}), + verify: async () => true, + }, + }, +}); const remoteHash = `sha384-${'J'.repeat(64)}`; const assetHash = `sha384-${'K'.repeat(64)}`; @@ -36,6 +43,8 @@ globalThis.fetch = async () => ({ protocolMajor: 1, version: '1.7.0', remoteEntry: './remoteEntry.js', + expiresAt: '2999-01-01T00:00:00.000Z', + signature: 'AA==', assetBase: '../assets/', chunks: { 'remoteEntry.js': remoteHash, diff --git a/packages/widget/js/tests/loader-singleflight.test.mjs b/packages/widget/js/tests/loader-singleflight.test.mjs index d2accc18c8..8de0ec91ca 100644 --- a/packages/widget/js/tests/loader-singleflight.test.mjs +++ b/packages/widget/js/tests/loader-singleflight.test.mjs @@ -2,13 +2,22 @@ import assert from 'node:assert/strict'; import test from 'node:test'; globalThis.window = { location: { href: 'https://host.example/page' } }; -globalThis.__LAYERSWAP_WIDGET_MANIFEST__ = 'https://cdn.example/v1/manifest.json'; -globalThis.__LAYERSWAP_WIDGET_VERIFY__ = false; +Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { + subtle: { + importKey: async () => ({}), + verify: async () => true, + }, + }, +}); let fetchCount = 0; let failNext = false; -globalThis.fetch = async () => { +let requestedManifestUrl; +globalThis.fetch = async (url) => { fetchCount++; + requestedManifestUrl = url; if (failNext) { failNext = false; return { ok: false, status: 503, url: 'https://cdn.example/v1/manifest.json', json: async () => ({}) }; @@ -20,24 +29,15 @@ globalThis.fetch = async () => { protocolMajor: 1, version: '1.7.0', remoteEntry: './remoteEntry.js', + expiresAt: '2999-01-01T00:00:00.000Z', + signature: 'AA==', }), }; }; const { resolveSource } = await import('../dist/esm/loader.js'); -test('concurrent mounts share a single manifest fetch and verification', async () => { - const before = fetchCount; - const [a, b, c] = await Promise.all([resolveSource(), resolveSource(), resolveSource()]); - assert.equal(fetchCount, before + 1); - assert.equal(a.remoteEntry, b.remoteEntry); - assert.equal(b.remoteEntry, c.remoteEntry); -}); - test('a failed resolution is not cached', async () => { - // Point at a distinct manifest URL so this test does not hit the fresh - // success cached by the previous test. - globalThis.__LAYERSWAP_WIDGET_MANIFEST__ = 'https://cdn.example/v2/manifest.json'; failNext = true; await assert.rejects(() => resolveSource()); const failedCount = fetchCount; @@ -45,3 +45,18 @@ test('a failed resolution is not cached', async () => { assert.equal(fetchCount, failedCount + 1); assert.equal(retry.remoteEntry, 'https://cdn.example/1.7.0-0123456789ab/remoteEntry.js'); }); + +test('the manifest source is fixed by the loader and single-flighted across concurrent mounts', async () => { + const before = fetchCount; + const realDateNow = Date.now; + Date.now = () => realDateNow() + 60_001; + try { + const [a, b, c] = await Promise.all([resolveSource(), resolveSource(), resolveSource()]); + assert.equal(fetchCount, before + 1); + assert.equal(requestedManifestUrl, 'https://layerswap-widget-cdn.layerswapcdn.workers.dev/v1/manifest.json'); + assert.equal(a.remoteEntry, b.remoteEntry); + assert.equal(b.remoteEntry, c.remoteEntry); + } finally { + Date.now = realDateNow; + } +}); diff --git a/packages/widget/js/tests/loader-verification.test.mjs b/packages/widget/js/tests/loader-verification.test.mjs new file mode 100644 index 0000000000..e96ee45685 --- /dev/null +++ b/packages/widget/js/tests/loader-verification.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +globalThis.window = { location: { href: 'https://host.example/page' } }; +Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { + subtle: { + importKey: async () => ({}), + verify: async () => false, + }, + }, +}); + +globalThis.fetch = async () => ({ + ok: true, + url: 'https://cdn.example/1.7.0-0123456789ab/manifest.json', + json: async () => ({ + protocolMajor: 1, + version: '1.7.0', + remoteEntry: './remoteEntry.js', + expiresAt: '2999-01-01T00:00:00.000Z', + signature: 'AA==', + }), +}); + +const { resolveSource } = await import('../dist/esm/loader.js'); + +test('the loader always rejects a manifest that fails signature verification', async () => { + await assert.rejects( + () => resolveSource(), + (error) => error?.name === 'ManifestError' && error?.reason === 'signature', + ); +}); diff --git a/packages/widget/js/tests/protocol.test.mjs b/packages/widget/js/tests/protocol.test.mjs index b38a93cb32..394291ec08 100644 --- a/packages/widget/js/tests/protocol.test.mjs +++ b/packages/widget/js/tests/protocol.test.mjs @@ -2,8 +2,15 @@ import assert from 'node:assert/strict'; import test from 'node:test'; globalThis.window = { location: { href: 'https://host.example/page' } }; -globalThis.__LAYERSWAP_WIDGET_MANIFEST__ = 'https://cdn.example/v1/manifest.json'; -globalThis.__LAYERSWAP_WIDGET_VERIFY__ = false; +Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { + subtle: { + importKey: async () => ({}), + verify: async () => true, + }, + }, +}); const { WIDGET_PROTOCOL_MAJOR } = await import('../dist/esm/index.js'); @@ -23,6 +30,8 @@ test('rejects a remote from another protocol major', async () => { protocolMajor: WIDGET_PROTOCOL_MAJOR + 1, version: '2.0.0', remoteEntry: './remoteEntry.js', + expiresAt: '2999-01-01T00:00:00.000Z', + signature: 'AA==', }), }); @@ -41,6 +50,8 @@ test('accepts a signed legacy manifest whose channel identifies protocol v1', as channel: 'v1', version: '1.7.0', remoteEntry: './remoteEntry.js', + expiresAt: '2999-01-01T00:00:00.000Z', + signature: 'AA==', }), }); diff --git a/packages/widget/react/README.md b/packages/widget/react/README.md index 4634c1b800..07a62157d9 100644 --- a/packages/widget/react/README.md +++ b/packages/widget/react/README.md @@ -139,7 +139,7 @@ accordingly. The widget is served from the fixed origin baked into this package release — currently `https://layerswap-widget-cdn.layerswapcdn.workers.dev` (the -`DEFAULT_MANIFEST_URL` in `@layerswap/widget-js`; if a future release moves +`WIDGET_MANIFEST_URL` in `@layerswap/widget-js`; if a future release moves to a custom domain such as `cdn.layerswap.io`, this section moves with it). A tight CSP that allowlists exactly that origin plus the LayerSwap endpoints gives integrators the smallest blast radius if the supply chain is ever @@ -187,23 +187,11 @@ Notes: ## Local development -The widget's source is fixed to the production CDN and is not overridable -through props. For working **on the widget itself** inside this monorepo, -the loader reads an internal, undocumented override from `globalThis` so -Layerswap's own harnesses can target the local widget-cdn dev server: - -```ts -// Set BEFORE mounts (e.g. at module scope). Not part of -// the public API — a build/test seam for the monorepo only. -globalThis.__LAYERSWAP_WIDGET_MANIFEST__ = 'http://127.0.0.1:3100/manifest.json'; -globalThis.__LAYERSWAP_WIDGET_VERIFY__ = false; // dev manifest is unsigned -``` - -Run the widget-cdn dev server (`pnpm dev` in `apps/widget-cdn`) — it serves -both `remoteEntry.js` and an unsigned `manifest.json` at -`http://127.0.0.1:3100`. See `examples/widget-react-host/` for a runnable -Vite host that sets these globals from `VITE_LAYERSWAP_MANIFEST` / -`VITE_LAYERSWAP_VERIFY` and wires `wagmiConfig` adoption and callbacks. +The widget source and signature verification policy are owned entirely by +`@layerswap/widget-js`. They cannot be changed through props, environment +variables, or globals. The runnable Vite host in +`examples/widget-react-host/` therefore exercises the same signed production +channel as an integrator. ## Security model From 780aa010535cf95623822b44bd5794584aae2e61 Mon Sep 17 00:00:00 2001 From: babkenmes Date: Tue, 4 Aug 2026 14:56:37 +0400 Subject: [PATCH 07/24] ci: expose Azure sandbox manifest public key --- .github/workflows/widget-cdn-azure-sandbox.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/widget-cdn-azure-sandbox.yml b/.github/workflows/widget-cdn-azure-sandbox.yml index 23573231f6..5293d1a5f5 100644 --- a/.github/workflows/widget-cdn-azure-sandbox.yml +++ b/.github/workflows/widget-cdn-azure-sandbox.yml @@ -75,7 +75,23 @@ jobs: umask 077 KEY_PATH="$RUNNER_TEMP/layerswap-azure-test-signing.pem" openssl ecparam -name prime256v1 -genkey -noout -out "$KEY_PATH" + PUBLIC_KEY=$(openssl ec -in "$KEY_PATH" -pubout -outform DER 2>/dev/null | base64 | tr -d '\n') echo "key-path=$KEY_PATH" >> "$GITHUB_OUTPUT" + echo "public-key=$PUBLIC_KEY" >> "$GITHUB_OUTPUT" + + { + echo "### Azure sandbox manifest public key" + echo + echo "Copy this value into \`MANIFEST_VERIFY_PUBLIC_KEY_SPKI_B64\` to verify this deployment locally:" + echo + echo '```text' + echo "$PUBLIC_KEY" + echo '```' + echo + echo "This key is ephemeral and changes on every sandbox deployment." + } >> "$GITHUB_STEP_SUMMARY" + + echo "::notice title=Azure sandbox manifest public key::$PUBLIC_KEY" - name: Build signed sandbox artifact working-directory: apps/widget-cdn From b7278e3affaa0f6ea5233e1c44979852c0859441 Mon Sep 17 00:00:00 2001 From: babkenmes Date: Tue, 4 Aug 2026 14:57:17 +0400 Subject: [PATCH 08/24] test: expect Azure widget manifest URL --- packages/widget/js/tests/loader-singleflight.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/widget/js/tests/loader-singleflight.test.mjs b/packages/widget/js/tests/loader-singleflight.test.mjs index 8de0ec91ca..55ddace794 100644 --- a/packages/widget/js/tests/loader-singleflight.test.mjs +++ b/packages/widget/js/tests/loader-singleflight.test.mjs @@ -53,7 +53,7 @@ test('the manifest source is fixed by the loader and single-flighted across conc try { const [a, b, c] = await Promise.all([resolveSource(), resolveSource(), resolveSource()]); assert.equal(fetchCount, before + 1); - assert.equal(requestedManifestUrl, 'https://layerswap-widget-cdn.layerswapcdn.workers.dev/v1/manifest.json'); + assert.equal(requestedManifestUrl, 'https://layerswapcdntest.blob.core.windows.net/widget-cdn/v1/manifest.json'); assert.equal(a.remoteEntry, b.remoteEntry); assert.equal(b.remoteEntry, c.remoteEntry); } finally { From 1b3928e8f72846c9d2ed0e31f5b8789037aca9e4 Mon Sep 17 00:00:00 2001 From: babkenmes Date: Tue, 4 Aug 2026 15:27:29 +0400 Subject: [PATCH 09/24] fix: update MANIFEST_VERIFY_PUBLIC_KEY_SPKI_B64 to the correct value --- packages/widget/js/src/manifest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/widget/js/src/manifest.ts b/packages/widget/js/src/manifest.ts index 5fa6694de6..a43246e2fd 100644 --- a/packages/widget/js/src/manifest.ts +++ b/packages/widget/js/src/manifest.ts @@ -96,7 +96,7 @@ export type Manifest = { * update this constant plus the GitHub secret `LAYERSWAP_PRIVATE_KEY_PEM`. */ export const MANIFEST_VERIFY_PUBLIC_KEY_SPKI_B64 = - 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESuHFHbltz/hfcY+DzIrLq7Ixc4efHE8SLZdNg0pZZDHTfdwbqLpGk4461EgNranHLWnVsoAbyQ4IyHIVnRAVKw=='; + 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfNBeyO8end9fs+S+OJ2KmLuRvTG2E/tcDdoQ4CKCf96MAn1eKZC58qV5UrAgpizQoae4EwvfeJlPxHGubj6uoA=='; /** * Canonical Layerswap CDN manifest URL — the fixed source the loaders always From 75d9faf62d408ec9b768c7e8bba28ed776c1168e Mon Sep 17 00:00:00 2001 From: babkenmes Date: Tue, 4 Aug 2026 16:02:38 +0400 Subject: [PATCH 10/24] fix: use stable manifest signing key for Azure --- .../workflows/widget-cdn-azure-sandbox.yml | 34 +++++++------------ packages/widget/js/src/manifest.ts | 2 +- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/.github/workflows/widget-cdn-azure-sandbox.yml b/.github/workflows/widget-cdn-azure-sandbox.yml index 5293d1a5f5..9cd505fe1f 100644 --- a/.github/workflows/widget-cdn-azure-sandbox.yml +++ b/.github/workflows/widget-cdn-azure-sandbox.yml @@ -67,37 +67,29 @@ jobs: BUILD_ID=$(node --input-type=module -e "import { resolveBuildIdentity } from './apps/widget-cdn/scripts/build-id.mjs'; process.stdout.write(resolveBuildIdentity('./apps/widget-cdn').buildId)") echo "LAYERSWAP_BUILD_ID=${BUILD_ID}" >> "$GITHUB_ENV" - # Generate a new sandbox-only key for every run. The Blob delivery path - # can be tested without storing or exposing the production signing key. - - name: Generate ephemeral sandbox signing key + # Use the same stable signing identity as the published loader. Rotating + # this key during a normal deploy would break every existing installation. + - name: Materialize manifest signing key id: key + env: + PRIVATE_KEY_PEM: ${{ secrets.LAYERSWAP_PRIVATE_KEY_PEM }} run: | + if [ -z "$PRIVATE_KEY_PEM" ]; then + echo "::error::LAYERSWAP_PRIVATE_KEY_PEM is not configured" + exit 1 + fi umask 077 KEY_PATH="$RUNNER_TEMP/layerswap-azure-test-signing.pem" - openssl ecparam -name prime256v1 -genkey -noout -out "$KEY_PATH" - PUBLIC_KEY=$(openssl ec -in "$KEY_PATH" -pubout -outform DER 2>/dev/null | base64 | tr -d '\n') + printf '%s' "$PRIVATE_KEY_PEM" > "$KEY_PATH" echo "key-path=$KEY_PATH" >> "$GITHUB_OUTPUT" - echo "public-key=$PUBLIC_KEY" >> "$GITHUB_OUTPUT" - - { - echo "### Azure sandbox manifest public key" - echo - echo "Copy this value into \`MANIFEST_VERIFY_PUBLIC_KEY_SPKI_B64\` to verify this deployment locally:" - echo - echo '```text' - echo "$PUBLIC_KEY" - echo '```' - echo - echo "This key is ephemeral and changes on every sandbox deployment." - } >> "$GITHUB_STEP_SUMMARY" - - echo "::notice title=Azure sandbox manifest public key::$PUBLIC_KEY" - name: Build signed sandbox artifact working-directory: apps/widget-cdn env: LAYERSWAP_PRIVATE_KEY_PEM: ${{ steps.key.outputs.key-path }} - run: pnpm build + run: | + pnpm build + pnpm verify-manifest - name: Log in to personal Azure with OIDC uses: azure/login@v2 diff --git a/packages/widget/js/src/manifest.ts b/packages/widget/js/src/manifest.ts index a43246e2fd..5fa6694de6 100644 --- a/packages/widget/js/src/manifest.ts +++ b/packages/widget/js/src/manifest.ts @@ -96,7 +96,7 @@ export type Manifest = { * update this constant plus the GitHub secret `LAYERSWAP_PRIVATE_KEY_PEM`. */ export const MANIFEST_VERIFY_PUBLIC_KEY_SPKI_B64 = - 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfNBeyO8end9fs+S+OJ2KmLuRvTG2E/tcDdoQ4CKCf96MAn1eKZC58qV5UrAgpizQoae4EwvfeJlPxHGubj6uoA=='; + 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESuHFHbltz/hfcY+DzIrLq7Ixc4efHE8SLZdNg0pZZDHTfdwbqLpGk4461EgNranHLWnVsoAbyQ4IyHIVnRAVKw=='; /** * Canonical Layerswap CDN manifest URL — the fixed source the loaders always From 3c0d747ee87e2a6128362e3a0e2b431b84dee0e0 Mon Sep 17 00:00:00 2001 From: babkenmes Date: Tue, 4 Aug 2026 17:42:11 +0400 Subject: [PATCH 11/24] feat: expose Deposit Widget through CDN remote and loader packages - @layerswap/widget-types: shared DepositWidgetProps/DepositConfig contract - widget-cdn: new ./DepositWidget and ./mountDeposit MF exposes; shared useWalletProviders hook and mountRoot single-live-mount guard - @layerswap/widget-js: mountDepositWidget + generic WidgetHandle - @layerswap/widget-react: LayerswapDepositWidget via shared RemoteWidgetHost - bundle budget is now enforced per expose (a page mounts exactly one widget) - widget-react-host example: Swap/Deposit tab toggle Co-Authored-By: Claude Fable 5 --- apps/widget-cdn/README.md | 3 +- apps/widget-cdn/rspack.config.mjs | 6 + .../scripts/check-bundle-budget.mjs | 53 +++--- apps/widget-cdn/src/DepositWidget.tsx | 50 ++++++ apps/widget-cdn/src/Widget.tsx | 46 +----- apps/widget-cdn/src/mount.tsx | 47 +----- apps/widget-cdn/src/mountDeposit.tsx | 20 +++ apps/widget-cdn/src/mountRoot.tsx | 61 +++++++ apps/widget-cdn/src/useWalletProviders.ts | 62 +++++++ examples/widget-react-host/README.md | 6 + examples/widget-react-host/src/App.tsx | 91 ++++++++--- packages/widget/js/src/index.ts | 8 +- packages/widget/js/src/mount.ts | 81 ++++++++-- packages/widget/js/src/types.ts | 4 + packages/widget/react/README.md | 28 ++++ .../react/src/LayerswapDepositWidget.tsx | 45 ++++++ packages/widget/react/src/LayerswapWidget.tsx | 152 +++--------------- packages/widget/react/src/index.ts | 7 + .../widget/react/src/remoteWidgetHost.tsx | 150 +++++++++++++++++ .../widget/react/tests/widget-props.test.ts | 37 ++++- packages/widget/types/src/config.ts | 79 +++++++++ packages/widget/types/src/index.ts | 4 + 22 files changed, 766 insertions(+), 274 deletions(-) create mode 100644 apps/widget-cdn/src/DepositWidget.tsx create mode 100644 apps/widget-cdn/src/mountDeposit.tsx create mode 100644 apps/widget-cdn/src/mountRoot.tsx create mode 100644 apps/widget-cdn/src/useWalletProviders.ts create mode 100644 packages/widget/react/src/LayerswapDepositWidget.tsx create mode 100644 packages/widget/react/src/remoteWidgetHost.tsx diff --git a/apps/widget-cdn/README.md b/apps/widget-cdn/README.md index 9c475e7234..4182dc3ef2 100644 --- a/apps/widget-cdn/README.md +++ b/apps/widget-cdn/README.md @@ -5,7 +5,8 @@ runtime delivery, plus the Cloudflare **Worker + R2** edge that serves it. - `name`: `layerswap_widget` - `filename`: `remoteEntry.js` -- `exposes`: `./Widget`, `./mount` +- `exposes`: `./Widget`, `./mount` (swap widget), `./DepositWidget`, + `./mountDeposit` (deposit widget — fixed-destination funding flow) - Shared singletons: `react`, `react-dom`, `wagmi`, `viem`, `@tanstack/react-query`, `zustand` diff --git a/apps/widget-cdn/rspack.config.mjs b/apps/widget-cdn/rspack.config.mjs index a66c09daef..c4baeb0bfa 100644 --- a/apps/widget-cdn/rspack.config.mjs +++ b/apps/widget-cdn/rspack.config.mjs @@ -203,6 +203,12 @@ export default (env, argv) => { // Imperative mount entry for framework-agnostic hosts. Owns its own // React root so non-React pages can embed it without a host framework. './mount': './src/mount.tsx', + // Deposit widget: fixed-destination funding flow. Same pairing — + // React hosts consume the component, vanilla hosts the mount. + // Additive expose: older loaders never request it, so this rolls + // within protocol v1. + './DepositWidget': './src/DepositWidget.tsx', + './mountDeposit': './src/mountDeposit.tsx', }, shared: SHARED_SINGLETONS, // Disable MF's dev-only live-reload bridge. With the remote consumed diff --git a/apps/widget-cdn/scripts/check-bundle-budget.mjs b/apps/widget-cdn/scripts/check-bundle-budget.mjs index c5a7fd6cc2..f5ffeb4927 100644 --- a/apps/widget-cdn/scripts/check-bundle-budget.mjs +++ b/apps/widget-cdn/scripts/check-bundle-budget.mjs @@ -4,6 +4,13 @@ // or QR/canvas library slipping back into the root import graph) landed us at // 842 KB gzip once; this guard keeps the fix from silently eroding. // +// The budget applies PER EXPOSE, not to the union of all exposes: a page +// mounts exactly one widget (`./Widget`/`./mount` or `./DepositWidget`/ +// `./mountDeposit`), so the bytes a host actually downloads before first +// paint are one expose's sync set. Their graphs overlap almost entirely; +// summing the union would count each sibling widget's private chunk against +// every other widget's budget. +// // Budget override (bytes): WIDGET_SYNC_GZIP_BUDGET env var. Raise it only for // a deliberate, reviewed increase — not to make a red build green. import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; @@ -31,32 +38,42 @@ const statsPath = statsFiles[0]; const stats = JSON.parse(readFileSync(statsPath, 'utf8')); const buildDir = dirname(statsPath); -const syncAssets = new Set(); -for (const expose of stats.exposes ?? []) { - for (const asset of expose.assets?.js?.sync ?? []) syncAssets.add(asset); -} -if (syncAssets.size === 0) { +const exposes = (stats.exposes ?? []).filter((e) => (e.assets?.js?.sync ?? []).length > 0); +if (exposes.length === 0) { console.error(`[bundle-budget] ${statsPath} lists no synchronous expose assets — stats format changed?`); process.exit(1); } -let totalGzip = 0; -const rows = []; -for (const asset of syncAssets) { - const assetPath = normalize(join(buildDir, asset)); - const gz = gzipSync(readFileSync(assetPath), { level: 6 }).length; - totalGzip += gz; - rows.push({ asset, gz }); -} -rows.sort((a, b) => b.gz - a.gz); +// gzip each distinct asset once — the exposes' sync sets overlap heavily. +const gzCache = new Map(); +const gzipOf = (asset) => { + if (!gzCache.has(asset)) { + const assetPath = normalize(join(buildDir, asset)); + gzCache.set(asset, gzipSync(readFileSync(assetPath), { level: 6 }).length); + } + return gzCache.get(asset); +}; const kib = (n) => `${(n / 1024).toFixed(1)} KiB`; -for (const { asset, gz } of rows) console.log(`[bundle-budget] ${kib(gz).padStart(10)} ${asset}`); -console.log(`[bundle-budget] ${syncAssets.size} sync assets, ${kib(totalGzip)} gzip total (budget ${kib(budget)})`); +const perExpose = exposes.map((expose) => { + const assets = [...new Set(expose.assets.js.sync)]; + const rows = assets + .map((asset) => ({ asset, gz: gzipOf(asset) })) + .sort((a, b) => b.gz - a.gz); + return { name: expose.path ?? expose.name, rows, totalGzip: rows.reduce((sum, r) => sum + r.gz, 0) }; +}).sort((a, b) => b.totalGzip - a.totalGzip); + +// Detail the heaviest expose; one summary line for each of the rest. +const heaviest = perExpose[0]; +for (const { asset, gz } of heaviest.rows) console.log(`[bundle-budget] ${kib(gz).padStart(10)} ${asset}`); +for (const { name, rows, totalGzip } of perExpose) { + console.log(`[bundle-budget] ${name}: ${rows.length} sync assets, ${kib(totalGzip)} gzip (budget ${kib(budget)})`); +} -if (totalGzip > budget) { +const over = perExpose.filter((e) => e.totalGzip > budget); +if (over.length > 0) { console.error( - `[bundle-budget] FAIL: synchronous JS is ${kib(totalGzip)} gzip, over the ${kib(budget)} budget. ` + `[bundle-budget] FAIL: ${over.map((e) => `${e.name} is ${kib(e.totalGzip)} gzip`).join(', ')}, over the ${kib(budget)} per-expose budget. ` + 'Find what joined the sync graph (compare mf-stats.json sync lists against the previous build) and lazy-load it; ' + 'raise WIDGET_SYNC_GZIP_BUDGET only for a deliberate, reviewed increase.', ); diff --git a/apps/widget-cdn/src/DepositWidget.tsx b/apps/widget-cdn/src/DepositWidget.tsx new file mode 100644 index 0000000000..a8016b3b98 --- /dev/null +++ b/apps/widget-cdn/src/DepositWidget.tsx @@ -0,0 +1,50 @@ +import { FC, ReactNode } from 'react'; +import { + type LayerswapWidgetConfig, + type CallbacksContextType, +} from '@layerswap/widget'; +import { Deposit } from '@layerswap/widget/deposit'; +import { type DefaultWalletConfig } from '@layerswap/wallets'; +import type { DepositWidgetProps as SharedDepositWidgetProps } from '@layerswap/widget-types'; +import type { Config as WagmiConfig } from 'wagmi'; +import { useWalletProviders } from './useWalletProviders'; +import '@layerswap/widget/index.css'; + +/** + * Props the `./DepositWidget` expose accepts: the shared public contract from + * `@layerswap/widget-types` (the same shape `mountDepositWidget` and the React + * `LayerswapDepositWidget` forward) with its open slots bound to this app's + * precise internal types — same derivation scheme as `./Widget`. + * + * The deposit-specific fields (`destination`, `methods`, …) are structurally + * typed in the shared package; spreading them into `Deposit` below is what + * keeps the two declarations in lockstep — a divergence fails this file's + * typecheck. + */ +export type DepositWidgetProps = SharedDepositWidgetProps< + WagmiConfig, + ReactNode, + LayerswapWidgetConfig, + DefaultWalletConfig, + CallbacksContextType +>; + +const DepositWidget: FC = ({ + config, + walletDefaults, + walletProvidersConfig, + callbacks, + wagmiConfig, + ...depositProps +}) => { + const walletProviders = useWalletProviders(walletDefaults, walletProvidersConfig, wagmiConfig); + + // `Deposit` wraps `LayerswapProvider` itself (and installs its own + // deposit-shaped loading skeleton), so unlike `./Widget` there is no + // provider wrapper here. + return ( + + ); +}; + +export default DepositWidget; diff --git a/apps/widget-cdn/src/Widget.tsx b/apps/widget-cdn/src/Widget.tsx index f5b41337ae..7d47166eaf 100644 --- a/apps/widget-cdn/src/Widget.tsx +++ b/apps/widget-cdn/src/Widget.tsx @@ -1,17 +1,14 @@ -import { FC, ReactNode, useMemo } from 'react'; +import { FC, ReactNode } from 'react'; import { LayerswapProvider, Swap, type LayerswapWidgetConfig, type CallbacksContextType, } from '@layerswap/widget'; -import { - getDefaultProviders, - createEVMProvider, - type DefaultWalletConfig, -} from '@layerswap/wallets'; +import { type DefaultWalletConfig } from '@layerswap/wallets'; import type { WidgetProps as SharedWidgetProps } from '@layerswap/widget-types'; import type { Config as WagmiConfig } from 'wagmi'; +import { useWalletProviders } from './useWalletProviders'; import '@layerswap/widget/index.css'; /** Wallet provider ids matching what `getDefaultProviders()` emits. */ @@ -35,42 +32,7 @@ export type WidgetProps = SharedWidgetProps< export type WalletProvidersConfig = NonNullable; const Widget: FC = ({ config, walletDefaults, walletProvidersConfig, callbacks, wagmiConfig }) => { - const walletProviders = useMemo(() => { - let providers = getDefaultProviders(walletDefaults ?? {}); - - // Allowlist — keep only the requested ids. Filtering here (before any - // `loadProvider()` call) means dropped chains never dynamic-import their - // SDK, so an `include` list gets the same lazy-loading win as `exclude`. - const included = walletProvidersConfig?.include; - if (included && included.length > 0) { - const keep = new Set(included); - providers = providers.filter((p) => keep.has(p.id)); - } - - // Blocklist — drop the requested ids. Applied after `include` so the two - // can be combined. - const excluded = walletProvidersConfig?.exclude; - if (excluded && excluded.length > 0) { - const drop = new Set(excluded); - providers = providers.filter((p) => !drop.has(p.id)); - } - - if (wagmiConfig) { - // Replace the eager EVM provider with one that adopts the host's wagmi - // config so the widget tracks the host's account/chain. Only do this - // when EVM is actually in the resolved set — respect the include/exclude - // lists literally rather than force-injecting EVM. - const evmIndex = providers.findIndex((p) => p.id === 'evm'); - if (evmIndex >= 0) { - providers[evmIndex] = createEVMProvider({ - walletConnectConfigs: walletDefaults?.walletConnect, - wagmiConfig, - }); - } - } - - return providers; - }, [wagmiConfig, walletDefaults, walletProvidersConfig]); + const walletProviders = useWalletProviders(walletDefaults, walletProvidersConfig, wagmiConfig); return ( diff --git a/apps/widget-cdn/src/mount.tsx b/apps/widget-cdn/src/mount.tsx index df5359f05a..35a3ddb0a5 100644 --- a/apps/widget-cdn/src/mount.tsx +++ b/apps/widget-cdn/src/mount.tsx @@ -1,54 +1,19 @@ -import { createRoot } from 'react-dom/client'; import Widget, { type WidgetProps } from './Widget'; +import { mountRoot, type MountHandle } from './mountRoot'; /** Handle returned by `mount` for updating props or tearing down. */ -export type WidgetHandle = { - update(props: WidgetProps): void; - destroy(): void; -}; - -// The widget keeps process-global state (API client key, app settings, the -// extended-route registry), so two live roots would cross-contaminate. Track -// live mounts and fail the second one synchronously — clearer than the -// in-tree fallback that LayerswapProvider renders as a backstop. -let liveMounts = 0; +export type WidgetHandle = MountHandle; /** - * Imperative mount entry for non-React hosts. + * Imperative mount entry for non-React hosts — the swap widget. * * Owns its own React root so the host needs no React at all. * Framework-agnostic loaders load this expose (`./mount`) and call it; React * hosts use the `./Widget` expose instead and share the host's React. * - * Only one widget may be live per page: a second `mount()` before the first - * handle's `destroy()` throws. + * Only one widget may be live per page (shared with `./mountDeposit` — see + * `mountRoot`): a second mount before the first handle's `destroy()` throws. */ export default function mount(target: HTMLElement, props: WidgetProps): WidgetHandle { - if (liveMounts > 0) { - throw new Error( - '[layerswap/widget] mount() was called while another widget is live. ' - + 'The widget keeps process-global state, so only one widget root may be mounted per page. ' - + 'Call destroy() on the existing handle first.', - ); - } - // Claim the slot only after createRoot succeeds — a bad `target` throwing here - // must not leave the counter stuck and lock out every future mount. - const root = createRoot(target); - liveMounts++; - root.render(); - let destroyed = false; - return { - update(next: WidgetProps) { - if (destroyed) { - throw new Error('[layerswap/widget] update() called on a destroyed widget handle'); - } - root.render(); - }, - destroy() { - if (destroyed) return; - destroyed = true; - liveMounts--; - root.unmount(); - }, - }; + return mountRoot(Widget, target, props); } diff --git a/apps/widget-cdn/src/mountDeposit.tsx b/apps/widget-cdn/src/mountDeposit.tsx new file mode 100644 index 0000000000..23d9ba7c94 --- /dev/null +++ b/apps/widget-cdn/src/mountDeposit.tsx @@ -0,0 +1,20 @@ +import DepositWidget, { type DepositWidgetProps } from './DepositWidget'; +import { mountRoot, type MountHandle } from './mountRoot'; + +/** Handle returned by `mountDeposit` for updating props or tearing down. */ +export type DepositWidgetHandle = MountHandle; + +/** + * Imperative mount entry for non-React hosts — the deposit widget. + * + * Owns its own React root so the host needs no React at all. + * Framework-agnostic loaders load this expose (`./mountDeposit`) and call it; + * React hosts use the `./DepositWidget` expose instead and share the host's + * React. + * + * Only one widget may be live per page (shared with `./mount` — see + * `mountRoot`): a second mount before the first handle's `destroy()` throws. + */ +export default function mountDeposit(target: HTMLElement, props: DepositWidgetProps): DepositWidgetHandle { + return mountRoot(DepositWidget, target, props); +} diff --git a/apps/widget-cdn/src/mountRoot.tsx b/apps/widget-cdn/src/mountRoot.tsx new file mode 100644 index 0000000000..bb0fc5ad21 --- /dev/null +++ b/apps/widget-cdn/src/mountRoot.tsx @@ -0,0 +1,61 @@ +import { ComponentType, createElement } from 'react'; +import { createRoot } from 'react-dom/client'; + +/** Handle returned by the mount exposes for updating props or tearing down. */ +export type MountHandle

= { + update(props: P): void; + destroy(): void; +}; + +// The widget keeps process-global state (API client key, app settings, the +// extended-route registry), so two live roots would cross-contaminate. Track +// live mounts and fail the second one synchronously — clearer than the +// in-tree fallback that LayerswapProvider renders as a backstop. +// +// The counter is shared across ALL mount exposes (`./mount`, `./mountDeposit`): +// a swap widget and a deposit widget sit on the same global state, so only one +// of either kind may be live per page. +let liveMounts = 0; + +/** + * Imperative mount core for non-React hosts, shared by the `./mount` (swap) + * and `./mountDeposit` exposes. Owns its own React root so the host needs no + * React at all; React hosts use the component exposes instead and share the + * host's React. + * + * Only one widget may be live per page: a second mount before the first + * handle's `destroy()` throws. + */ +export function mountRoot

( + Component: ComponentType

, + target: HTMLElement, + props: P, +): MountHandle

{ + if (liveMounts > 0) { + throw new Error( + '[layerswap/widget] mount() was called while another widget is live. ' + + 'The widget keeps process-global state, so only one widget root may be mounted per page. ' + + 'Call destroy() on the existing handle first.', + ); + } + // Claim the slot only after createRoot succeeds — a bad `target` throwing here + // must not leave the counter stuck and lock out every future mount. + const root = createRoot(target); + liveMounts++; + root.render(createElement(Component, props)); + let destroyed = false; + return { + update(next: P) { + if (destroyed) { + throw new Error('[layerswap/widget] update() called on a destroyed widget handle'); + } + root.render(createElement(Component, next)); + }, + destroy() { + if (destroyed) return; + destroyed = true; + liveMounts--; + root.unmount(); + }, + }; +} diff --git a/apps/widget-cdn/src/useWalletProviders.ts b/apps/widget-cdn/src/useWalletProviders.ts new file mode 100644 index 0000000000..8d3ede5d9b --- /dev/null +++ b/apps/widget-cdn/src/useWalletProviders.ts @@ -0,0 +1,62 @@ +import { useMemo } from 'react'; +import { + getDefaultProviders, + createEVMProvider, + type DefaultWalletConfig, +} from '@layerswap/wallets'; +import type { WalletProviderId } from '@layerswap/widget-types'; +import type { Config as WagmiConfig } from 'wagmi'; + +export type WalletProvidersConfig = { + include?: Array; + exclude?: Array; +}; + +/** + * Resolve the wallet provider set for a remote widget mount from the shared + * loader-facing props: defaults → include/exclude filtering → host wagmi + * adoption. Shared by the `./Widget` (swap) and `./DepositWidget` exposes so + * the two surfaces resolve providers identically. + */ +export function useWalletProviders( + walletDefaults: DefaultWalletConfig | undefined, + walletProvidersConfig: WalletProvidersConfig | undefined, + wagmiConfig: WagmiConfig | undefined, +): ReturnType { + return useMemo(() => { + let providers = getDefaultProviders(walletDefaults ?? {}); + + // Allowlist — keep only the requested ids. Filtering here (before any + // `loadProvider()` call) means dropped chains never dynamic-import their + // SDK, so an `include` list gets the same lazy-loading win as `exclude`. + const included = walletProvidersConfig?.include; + if (included && included.length > 0) { + const keep = new Set(included); + providers = providers.filter((p) => keep.has(p.id)); + } + + // Blocklist — drop the requested ids. Applied after `include` so the two + // can be combined. + const excluded = walletProvidersConfig?.exclude; + if (excluded && excluded.length > 0) { + const drop = new Set(excluded); + providers = providers.filter((p) => !drop.has(p.id)); + } + + if (wagmiConfig) { + // Replace the eager EVM provider with one that adopts the host's wagmi + // config so the widget tracks the host's account/chain. Only do this + // when EVM is actually in the resolved set — respect the include/exclude + // lists literally rather than force-injecting EVM. + const evmIndex = providers.findIndex((p) => p.id === 'evm'); + if (evmIndex >= 0) { + providers[evmIndex] = createEVMProvider({ + walletConnectConfigs: walletDefaults?.walletConnect, + wagmiConfig, + }); + } + } + + return providers; + }, [wagmiConfig, walletDefaults, walletProvidersConfig]); +} diff --git a/examples/widget-react-host/README.md b/examples/widget-react-host/README.md index 7b9743e81d..ee71114b2a 100644 --- a/examples/widget-react-host/README.md +++ b/examples/widget-react-host/README.md @@ -16,6 +16,12 @@ from its built-in production channel, then loads the `remoteEntry.js` it points at and mounts the widget. The host cannot replace the manifest URL or disable verification. +The page has two tabs: **Swap** mounts `LayerswapWidget` (the full swap form) +and **Deposit** mounts `LayerswapDepositWidget` (the fixed-destination funding +flow, pointed at a demo Base USDC/ETH recipient). They are tabs rather than +side-by-side because only one Layerswap widget may be live per page — the +widget keeps process-global state. + ## What's in the host bundle Only `@layerswap/widget-react` (a few KB plus `@module-federation/runtime`). diff --git a/examples/widget-react-host/src/App.tsx b/examples/widget-react-host/src/App.tsx index d032fe6e12..d0780784c4 100644 --- a/examples/widget-react-host/src/App.tsx +++ b/examples/widget-react-host/src/App.tsx @@ -1,12 +1,47 @@ +import { useState } from 'react'; import { WagmiProvider } from 'wagmi'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { LayerswapWidget } from '@layerswap/widget-react'; +import { LayerswapWidget, LayerswapDepositWidget } from '@layerswap/widget-react'; import { wagmiConfig } from './wagmi'; import { HostWallet } from './HostWallet'; const queryClient = new QueryClient(); +// Demo recipient for the deposit tab. A real integrator supplies their own +// deposit address here — the deposit widget never asks the end user for it. +const DEPOSIT_DEMO_ADDRESS = '0x2fc617e933a52713247ce25730f6695920b3befe'; + +type WidgetTab = 'swap' | 'deposit'; + +const loadingFallback = ( +

+ Loading widget… +
+); + export function App() { + // Only one Layerswap widget (of either kind) may be live per page — the + // widget keeps process-global state. Tabs swap between them rather than + // rendering both. + const [tab, setTab] = useState('swap'); + + const tabButton = (value: WidgetTab, label: string) => ( + + ); + return ( @@ -30,25 +65,43 @@ export function App() { compare its account against what the widget sees.

+
+ {tabButton('swap', 'Swap')} + {tabButton('deposit', 'Deposit')} +
- console.log('[host] swap created', swap), - onSwapComplete: (swap) => console.log('[host] swap complete', swap), - onError: (err) => console.warn('[host] widget error', err), - onSwapModalStateChange: (open) => console.log('[host] swap modal', open), - }} - fallback={ -
- Loading widget… -
- } - onReady={() => console.log('[embed] widget ready')} - onError={(err) => console.error('[embed] failed to load', err)} - /> + {tab === 'swap' ? ( + console.log('[host] swap created', swap), + onSwapComplete: (swap) => console.log('[host] swap complete', swap), + onError: (err) => console.warn('[host] widget error', err), + onSwapModalStateChange: (open) => console.log('[host] swap modal', open), + }} + fallback={loadingFallback} + onReady={() => console.log('[embed] widget ready')} + onError={(err) => console.error('[embed] failed to load', err)} + /> + ) : ( + console.log('[host] deposit swap created', swap), + onSwapComplete: (swap) => console.log('[host] deposit complete', swap), + onError: (err) => console.warn('[host] deposit widget error', err), + }} + fallback={loadingFallback} + onReady={() => console.log('[embed] deposit widget ready')} + onError={(err) => console.error('[embed] failed to load', err)} + /> + )}
diff --git a/packages/widget/js/src/index.ts b/packages/widget/js/src/index.ts index ebef13d4f8..906ab401ab 100644 --- a/packages/widget/js/src/index.ts +++ b/packages/widget/js/src/index.ts @@ -1,6 +1,6 @@ // Vanilla, framework-agnostic mount API — the primary entry point. -export { mountWidget } from "./mount.js"; -export type { WidgetHandle, MountOptions } from "./mount.js"; +export { mountWidget, mountDepositWidget } from "./mount.js"; +export type { WidgetHandle, DepositWidgetHandle, MountOptions } from "./mount.js"; // Shared widget prop/config/theme and protocol contract, re-exported from // `@layerswap/widget-types` (the single source of truth). @@ -10,6 +10,10 @@ export type { WidgetCallbacks, WalletDefaults, WalletProviderId, + DepositWidgetProps, + DepositConfig, + DepositMethodId, + SupportedDestination, ThemeData, ThemeColor, } from "@layerswap/widget-types"; diff --git a/packages/widget/js/src/mount.ts b/packages/widget/js/src/mount.ts index 9be89a4f67..207dd3b0c0 100644 --- a/packages/widget/js/src/mount.ts +++ b/packages/widget/js/src/mount.ts @@ -1,17 +1,24 @@ import { resolveSource } from './loader.js'; import { initRemote, loadRemoteModule, type SharedLib } from './runtime.js'; -import type { WidgetProps } from './types.js'; +import type { WidgetProps, DepositWidgetProps } from './types.js'; -/** Handle returned by `mountWidget` for updating props or tearing down. */ -export type WidgetHandle = { +/** + * Handle returned by `mountWidget` / `mountDepositWidget` for updating props + * or tearing down. Parameterized by the props shape of the mounted widget; + * defaults to the swap widget's for backward compatibility. + */ +export type WidgetHandle

= { /** Re-render the mounted widget with new props. */ - update(props: WidgetProps): void; + update(props: P): void; /** Unmount the widget and release its React root. */ destroy(): void; }; -/** Signature of the remote's `./mount` expose. */ -type RemoteMount = (target: HTMLElement, props: WidgetProps) => WidgetHandle; +/** Handle returned by `mountDepositWidget`. */ +export type DepositWidgetHandle = WidgetHandle; + +/** Signature of the remote's `./mount` / `./mountDeposit` exposes. */ +type RemoteMount

= (target: HTMLElement, props: P) => WidgetHandle

; export type MountOptions = { /** @@ -23,6 +30,31 @@ export type MountOptions = { shared?: Record; }; +/** + * Shared plumbing for the mount entries: resolve + verify the manifest, init + * the MF runtime, and call the requested remote mount expose. The + * security-critical path (signature check + SRI registration) lives in + * `resolveSource`, shared with the React loader. + */ +async function mountRemote

( + exposeName: string, + fnName: string, + target: HTMLElement, + props: P, + options: MountOptions, +): Promise> { + if (typeof window === 'undefined') { + throw new Error(`[layerswap/widget-js] ${fnName}() requires a browser environment`); + } + if (!target) { + throw new Error(`[layerswap/widget-js] ${fnName}(target, …) requires a DOM element`); + } + const { remoteEntry } = await resolveSource(); + initRemote(remoteEntry, options.shared); + const mount = await loadRemoteModule>(exposeName); + return mount(target, props); +} + /** * Fetch the Layerswap widget from the CDN and mount it into `target`. * @@ -49,14 +81,31 @@ export async function mountWidget( props: WidgetProps, options: MountOptions = {}, ): Promise { - if (typeof window === 'undefined') { - throw new Error('[layerswap/widget-js] mountWidget() requires a browser environment'); - } - if (!target) { - throw new Error('[layerswap/widget-js] mountWidget(target, …) requires a DOM element'); - } - const { remoteEntry } = await resolveSource(); - initRemote(remoteEntry, options.shared); - const mount = await loadRemoteModule('mount'); - return mount(target, props); + return mountRemote('mount', 'mountWidget', target, props, options); +} + +/** + * Fetch the Layerswap DEPOSIT widget from the CDN and mount it into `target`. + * + * The deposit widget funds a single, integrator-fixed destination + * (network + allowed tokens + recipient address) — the end user only picks a + * source. Same delivery pipeline and guarantees as `mountWidget`; only one + * widget (of either kind) may be live per page. + * + * ```js + * import { mountDepositWidget } from '@layerswap/widget-js'; + * + * const handle = await mountDepositWidget(document.getElementById('layerswap'), { + * config: { apiKey: 'mainnet' }, + * destination: { network: 'BASE_MAINNET', tokens: ['USDC'] }, + * destinationAddress: '0x…', + * }); + * ``` + */ +export async function mountDepositWidget( + target: HTMLElement, + props: DepositWidgetProps, + options: MountOptions = {}, +): Promise { + return mountRemote('mountDeposit', 'mountDepositWidget', target, props, options); } diff --git a/packages/widget/js/src/types.ts b/packages/widget/js/src/types.ts index 223cb4a87d..339c689608 100644 --- a/packages/widget/js/src/types.ts +++ b/packages/widget/js/src/types.ts @@ -7,4 +7,8 @@ export type { WidgetCallbacks, WalletDefaults, WalletProviderId, + DepositWidgetProps, + DepositConfig, + DepositMethodId, + SupportedDestination, } from "@layerswap/widget-types"; diff --git a/packages/widget/react/README.md b/packages/widget/react/README.md index 07a62157d9..8fa740f0d4 100644 --- a/packages/widget/react/README.md +++ b/packages/widget/react/README.md @@ -56,6 +56,34 @@ The npm package major selects the CDN protocol major: Minor and patch widget builds roll forward within that major without an integrator redeploy. Exact CDN builds are not a public pinning API. +## Deposit widget + +`LayerswapDepositWidget` renders the deposit flow instead of the full swap +form: you fix the destination (one network, its allowed tokens, and the +recipient address) and the end user only picks a funding source. Delivered +through the same verified manifest + Module Federation pipeline as +`LayerswapWidget`, and it accepts all of the same props plus the +deposit-specific ones (`DepositConfig` in `@layerswap/widget-types`). + +```tsx +import { LayerswapDepositWidget } from '@layerswap/widget-react'; + +export function DepositPage() { + return ( + Loading widget…} + /> + ); +} +``` + +Only one Layerswap widget (of either kind) may be live per page — the widget +keeps process-global state. Vanilla hosts use `mountDepositWidget` from +`@layerswap/widget-js` the same way they use `mountWidget`. + ## Reusing the host's wagmi config If your app already runs wagmi, pass its `Config` to the widget so EVM diff --git a/packages/widget/react/src/LayerswapDepositWidget.tsx b/packages/widget/react/src/LayerswapDepositWidget.tsx new file mode 100644 index 0000000000..e97d22721e --- /dev/null +++ b/packages/widget/react/src/LayerswapDepositWidget.tsx @@ -0,0 +1,45 @@ +'use client'; + +import type { ReactNode } from 'react'; +import type { Config as WagmiConfig } from 'wagmi'; +import type { DepositWidgetProps } from '@layerswap/widget-js'; +import { RemoteWidgetHost, type RemoteHostCallbacks } from './remoteWidgetHost.js'; + +/** + * Shape of the props the CDN remote's DEPOSIT widget export accepts — the + * shared framework-agnostic contract with its host-specific slots bound to + * this package's React/wagmi peers, mirroring `RemoteWidgetProps`. + */ +export type RemoteDepositWidgetProps = DepositWidgetProps; + +export type LayerswapDepositWidgetProps = RemoteDepositWidgetProps & RemoteHostCallbacks; + +/** + * Host-side React loader for the CDN-delivered Layerswap DEPOSIT widget — the + * fixed-destination funding flow (the integrator locks the destination + * network, tokens, and recipient address; the end user only picks a source). + * + * Delivered through the same verified manifest + Module Federation pipeline + * as `LayerswapWidget`; see `RemoteWidgetHost` for the SSR/hydration and + * loading behavior. + * + * ```tsx + * + * ``` + */ +export function LayerswapDepositWidget(props: LayerswapDepositWidgetProps) { + const { fallback, onReady, onError, ...rest } = props; + return ( + + ); +} diff --git a/packages/widget/react/src/LayerswapWidget.tsx b/packages/widget/react/src/LayerswapWidget.tsx index 38cd38f938..f071033b84 100644 --- a/packages/widget/react/src/LayerswapWidget.tsx +++ b/packages/widget/react/src/LayerswapWidget.tsx @@ -1,28 +1,9 @@ 'use client'; -import { - ComponentType, - ReactNode, - Suspense, - lazy, - useMemo, - useState, - useEffect, - useRef, - useCallback, - Component, - ErrorInfo, -} from 'react'; -import React from 'react'; -import ReactDOM from 'react-dom'; +import type { ReactNode } from 'react'; import type { Config as WagmiConfig } from 'wagmi'; -import { - resolveSource, - initRemote, - loadRemoteModule, - type WidgetProps, - type SharedLib, -} from '@layerswap/widget-js'; +import type { WidgetProps } from '@layerswap/widget-js'; +import { RemoteWidgetHost, type RemoteHostCallbacks } from './remoteWidgetHost.js'; /** Wallet provider ids matching what the remote's `getDefaultProviders()` emits. */ export type { WalletProviderId } from '@layerswap/widget-js'; @@ -36,124 +17,27 @@ export type { WalletProviderId } from '@layerswap/widget-js'; */ export type RemoteWidgetProps = WidgetProps; -export type LayerswapWidgetProps = RemoteWidgetProps & { - // The widget's source is not configurable: it is always fetched from the - // canonical Layerswap CDN baked into `@layerswap/widget-js`, with its - // manifest signature verified. This keeps the remote bundle's origin under - // Layerswap's control rather than the integrator's. - /** Shown while the remote bundle is being fetched / initialized. */ - fallback?: ReactNode; - /** Fired once the remote module has loaded and the widget mounts. */ - onReady?: () => void; - /** Fired when the remote fails to load or throws during render. */ - onError?: (error: unknown) => void; -}; - -type WidgetComponent = ComponentType; - -class WidgetErrorBoundary extends Component< - { fallback: ReactNode; onError?: (error: unknown) => void; children: ReactNode }, - { error: unknown } -> { - state = { error: null as unknown }; - - static getDerivedStateFromError(error: unknown) { - return { error }; - } - - componentDidCatch(error: unknown, _info: ErrorInfo) { - this.props.onError?.(error); - } - - render() { - if (this.state.error) return this.props.fallback ?? null; - return this.props.children; - } -} - -// Share the host's React/ReactDOM with the remote as MF singletons so the -// widget dedups onto the host's instance instead of bundling its own. The -// range mirrors this package's peerDependencies: accept React 18 and 19 (the -// widget relies on 18+ hook semantics) while rejecting 17, where those hooks -// don't exist. `requiredVersion: false` would silently dedup an incompatible -// host version. Vanilla (non-React) hosts use `@layerswap/widget-js` directly, -// which shares nothing and lets the remote bundle its own React. -function hostReactShare(): Record { - return { - react: { - version: (React as { version?: string }).version ?? '0.0.0', - lib: () => React, - requiredVersion: '^18.0.0 || ^19.0.0', - }, - 'react-dom': { - version: (ReactDOM as { version?: string }).version ?? '0.0.0', - lib: () => ReactDOM, - requiredVersion: '^18.0.0 || ^19.0.0', - }, - }; -} - -function buildLoader(): () => Promise<{ default: WidgetComponent }> { - return async () => { - const { remoteEntry } = await resolveSource(); - initRemote(remoteEntry, hostReactShare()); - const Widget = await loadRemoteModule('Widget'); - return { default: Widget }; - }; -} +export type LayerswapWidgetProps = RemoteWidgetProps & RemoteHostCallbacks; +// The widget's source is not configurable: it is always fetched from the +// canonical Layerswap CDN baked into `@layerswap/widget-js`, with its +// manifest signature verified. This keeps the remote bundle's origin under +// Layerswap's control rather than the integrator's. /** - * Host-side React loader for the CDN-delivered Layerswap widget. + * Host-side React loader for the CDN-delivered Layerswap swap widget. * - * Safe to import from anywhere in Next.js — the file declares `"use client"`, - * so App Router Server Components can render it directly. Because Client - * Components are still pre-rendered to HTML on the server, the component - * renders `fallback` until hydration completes and only then starts the - * browser-only work (manifest fetch, Module Federation init). + * Safe to import from anywhere in Next.js — see `RemoteWidgetHost` for the + * SSR/hydration and loading behavior. */ export function LayerswapWidget(props: LayerswapWidgetProps) { const { fallback, onReady, onError, ...rest } = props; - - // Hydration gate: `mounted` is false during SSR/prerender and the first - // client render, so server HTML and hydration output agree, and the - // browser-only loader never runs outside the browser. - const [mounted, setMounted] = useState(false); - useEffect(() => setMounted(true), []); - - // De-dup guard lives on the parent (which persists across Suspense - // re-resolution) rather than inside `ReadySignal`, so `onReady` fires - // exactly once per physical `LayerswapWidget` mount. - const onReadyFiredRef = useRef(false); - const stableOnReady = useCallback(() => { - if (onReadyFiredRef.current) return; - onReadyFiredRef.current = true; - onReady?.(); - }, [onReady]); - - // Per-mount, not module scope: `lazy()` caches a rejected loader promise - // forever, so a module-level instance would turn one transient CDN failure - // into a page-lifetime one. A fresh `lazy()` per mount lets the integrator - // recover by remounting; dedup of the underlying fetch is handled by - // `resolveSource()`'s single-flight, which never caches failures. - const LazyWidget = useMemo(() => lazy(buildLoader()), []); - - if (!mounted) return <>{fallback ?? null}; - return ( - - - - - - + ); } - -function ReadySignal({ onReady }: { onReady?: () => void }) { - useEffect(() => { - // Runs once per Suspense resolve; the parent's ref guard prevents the - // callback from double-firing across LazyWidget recreation. - onReady?.(); - }, [onReady]); - return null; -} diff --git a/packages/widget/react/src/index.ts b/packages/widget/react/src/index.ts index 75d2e977f2..90b51030e9 100644 --- a/packages/widget/react/src/index.ts +++ b/packages/widget/react/src/index.ts @@ -1,5 +1,7 @@ export { LayerswapWidget } from './LayerswapWidget.js'; export type { LayerswapWidgetProps, RemoteWidgetProps } from './LayerswapWidget.js'; +export { LayerswapDepositWidget } from './LayerswapDepositWidget.js'; +export type { LayerswapDepositWidgetProps, RemoteDepositWidgetProps } from './LayerswapDepositWidget.js'; // Manifest format + error type live in the framework-agnostic core. export type { Manifest } from '@layerswap/widget-js'; export { ManifestError } from '@layerswap/widget-js'; @@ -14,14 +16,19 @@ import type { Config as WagmiConfig } from 'wagmi'; import type { WidgetConfig as SharedWidgetConfig, WidgetProps as SharedWidgetProps, + DepositWidgetProps as SharedDepositWidgetProps, } from '@layerswap/widget-js'; export type WidgetConfig = SharedWidgetConfig; export type WidgetProps = SharedWidgetProps; +export type DepositWidgetProps = SharedDepositWidgetProps; export type { WidgetCallbacks, WalletDefaults, WalletProviderId, + DepositConfig, + DepositMethodId, + SupportedDestination, ThemeData, ThemeColor, } from '@layerswap/widget-js'; diff --git a/packages/widget/react/src/remoteWidgetHost.tsx b/packages/widget/react/src/remoteWidgetHost.tsx new file mode 100644 index 0000000000..22014b9d2d --- /dev/null +++ b/packages/widget/react/src/remoteWidgetHost.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { + ComponentType, + ReactNode, + Suspense, + lazy, + useMemo, + useState, + useEffect, + useRef, + useCallback, + Component, + ErrorInfo, +} from 'react'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { + resolveSource, + initRemote, + loadRemoteModule, + type SharedLib, +} from '@layerswap/widget-js'; + +/** + * Internal machinery shared by `LayerswapWidget` and `LayerswapDepositWidget`: + * hydration gating, remote loading via Module Federation, error containment, + * and the once-per-mount `onReady` signal. The two public components differ + * only in which remote expose they load and the props they forward. + */ + +export type RemoteHostCallbacks = { + /** Shown while the remote bundle is being fetched / initialized. */ + fallback?: ReactNode; + /** Fired once the remote module has loaded and the widget mounts. */ + onReady?: () => void; + /** Fired when the remote fails to load or throws during render. */ + onError?: (error: unknown) => void; +}; + +class WidgetErrorBoundary extends Component< + { fallback: ReactNode; onError?: (error: unknown) => void; children: ReactNode }, + { error: unknown } +> { + state = { error: null as unknown }; + + static getDerivedStateFromError(error: unknown) { + return { error }; + } + + componentDidCatch(error: unknown, _info: ErrorInfo) { + this.props.onError?.(error); + } + + render() { + if (this.state.error) return this.props.fallback ?? null; + return this.props.children; + } +} + +// Share the host's React/ReactDOM with the remote as MF singletons so the +// widget dedups onto the host's instance instead of bundling its own. The +// range mirrors this package's peerDependencies: accept React 18 and 19 (the +// widget relies on 18+ hook semantics) while rejecting 17, where those hooks +// don't exist. `requiredVersion: false` would silently dedup an incompatible +// host version. Vanilla (non-React) hosts use `@layerswap/widget-js` directly, +// which shares nothing and lets the remote bundle its own React. +function hostReactShare(): Record { + return { + react: { + version: (React as { version?: string }).version ?? '0.0.0', + lib: () => React, + requiredVersion: '^18.0.0 || ^19.0.0', + }, + 'react-dom': { + version: (ReactDOM as { version?: string }).version ?? '0.0.0', + lib: () => ReactDOM, + requiredVersion: '^18.0.0 || ^19.0.0', + }, + }; +} + +function buildLoader

(expose: string): () => Promise<{ default: ComponentType

}> { + return async () => { + const { remoteEntry } = await resolveSource(); + initRemote(remoteEntry, hostReactShare()); + const Widget = await loadRemoteModule>(expose); + return { default: Widget }; + }; +} + +function ReadySignal({ onReady }: { onReady?: () => void }) { + useEffect(() => { + // Runs once per Suspense resolve; the parent's ref guard prevents the + // callback from double-firing across LazyWidget recreation. + onReady?.(); + }, [onReady]); + return null; +} + +/** + * Host-side loader for one of the CDN remote's component exposes. + * + * Safe to render from anywhere in Next.js — this file declares `"use client"`, + * so App Router Server Components can render its consumers directly. Because + * Client Components are still pre-rendered to HTML on the server, it renders + * `fallback` until hydration completes and only then starts the browser-only + * work (manifest fetch, Module Federation init). + */ +export function RemoteWidgetHost

({ + expose, + widgetProps, + fallback, + onReady, + onError, +}: RemoteHostCallbacks & { expose: string; widgetProps: P }) { + // Hydration gate: `mounted` is false during SSR/prerender and the first + // client render, so server HTML and hydration output agree, and the + // browser-only loader never runs outside the browser. + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + // De-dup guard lives on the parent (which persists across Suspense + // re-resolution) rather than inside `ReadySignal`, so `onReady` fires + // exactly once per physical widget mount. + const onReadyFiredRef = useRef(false); + const stableOnReady = useCallback(() => { + if (onReadyFiredRef.current) return; + onReadyFiredRef.current = true; + onReady?.(); + }, [onReady]); + + // Per-mount, not module scope: `lazy()` caches a rejected loader promise + // forever, so a module-level instance would turn one transient CDN failure + // into a page-lifetime one. A fresh `lazy()` per mount lets the integrator + // recover by remounting; dedup of the underlying fetch is handled by + // `resolveSource()`'s single-flight, which never caches failures. + const LazyWidget = useMemo(() => lazy(buildLoader

(expose)), []); + + if (!mounted) return <>{fallback ?? null}; + + return ( + + + + + + + ); +} diff --git a/packages/widget/react/tests/widget-props.test.ts b/packages/widget/react/tests/widget-props.test.ts index a8dfc8f5dc..6481553b2e 100644 --- a/packages/widget/react/tests/widget-props.test.ts +++ b/packages/widget/react/tests/widget-props.test.ts @@ -1,6 +1,10 @@ import type { Config as WagmiConfig } from 'wagmi'; -import type { WidgetProps as VanillaWidgetProps } from '@layerswap/widget-js'; +import type { + WidgetProps as VanillaWidgetProps, + DepositWidgetProps as VanillaDepositWidgetProps, +} from '@layerswap/widget-js'; import type { RemoteWidgetProps } from '../src/LayerswapWidget'; +import type { RemoteDepositWidgetProps } from '../src/LayerswapDepositWidget'; const vanillaProps: VanillaWidgetProps = { config: { apiKey: 'mainnet' } }; @@ -22,7 +26,38 @@ const reactProps: RemoteWidgetProps = { config: { loadingComponent: 'Loading widget…' }, }; +const vanillaDepositProps: VanillaDepositWidgetProps = { + config: { apiKey: 'mainnet' }, + destination: { network: 'BASE_MAINNET', tokens: ['USDC'] }, + destinationAddress: '0x0000000000000000000000000000000000000000', + methods: ['wallet', 'deposit_address'], +}; + +// @ts-expect-error `destination` and `destinationAddress` are required. +const invalidDepositMissingDestination: VanillaDepositWidgetProps = { + config: { apiKey: 'mainnet' }, +}; + +const invalidDepositMethod: VanillaDepositWidgetProps = { + destination: { network: 'BASE_MAINNET', tokens: ['USDC'] }, + destinationAddress: '0x0000000000000000000000000000000000000000', + // @ts-expect-error Unknown deposit method id. + methods: ['bank_transfer'], +}; + +const reactDepositProps: RemoteDepositWidgetProps = { + wagmiConfig, + config: { loadingComponent: 'Loading widget…' }, + destination: { network: 'BASE_MAINNET', tokens: ['USDC'] }, + destinationAddress: '0x0000000000000000000000000000000000000000', + mode: 'button', +}; + void vanillaProps; void invalidVanillaWagmi; void invalidVanillaLoading; void reactProps; +void vanillaDepositProps; +void invalidDepositMissingDestination; +void invalidDepositMethod; +void reactDepositProps; diff --git a/packages/widget/types/src/config.ts b/packages/widget/types/src/config.ts index 77fdcff7f0..26e8e5d62e 100644 --- a/packages/widget/types/src/config.ts +++ b/packages/widget/types/src/config.ts @@ -93,6 +93,66 @@ export type WidgetCallbacks = { onMenuNavigationChange?: (path: string) => void; }; +/** + * The single destination the deposit widget funds. Structurally typed — the + * precise source of truth is `SupportedDestination` in `@layerswap/widget` + * (`components/Pages/Deposit/DestinationTokenPicker.tsx`); the CDN remote + * spreads these props into that component, so a divergence fails its + * typecheck rather than drifting silently. + */ +export type SupportedDestination = { + /** Network `name` (canonical identifier like `BASE_MAINNET`). */ + network: string; + /** Token symbols (case-insensitive, e.g. `["USDC", "USDT"]`). The user picks + * one of these via the token dropdown; the network is fixed. */ + tokens: string[]; +}; + +/** + * Deposit funding methods. Mirrors `DEPOSIT_METHODS` in `@layerswap/widget` + * (`components/Pages/Deposit/depositMethods.ts`) — kept in lockstep by the CDN + * remote's typecheck, same as {@link SupportedDestination}. + */ +export type DepositMethodId = + | "wallet" + | "deposit_address" + | "hyperliquid" + | "polymarket"; + +/** + * Deposit-widget-specific props — the integrator-facing surface of + * `DepositProps` in `@layerswap/widget`. `partner` is deliberately not part of + * the public contract (it is a Layerswap-internal model). + */ +export type DepositConfig = { + /** The single destination network and its allowed tokens. The network is + * fixed; the user picks one of the tokens via the token dropdown. */ + destination: SupportedDestination; + /** Recipient address on the destination network. Required — the deposit + * widget never asks the end user for this. */ + destinationAddress: string; + /** "inline" (default) renders the widget directly. "button" renders a Deposit + * button that opens the widget inside a dialog. */ + mode?: "inline" | "button"; + /** Title shown in the widget header. Defaults to "Deposit". */ + title?: string; + /** Label for the trigger button when mode="button". Defaults to "Deposit". */ + buttonLabel?: string; + /** Extra className applied to the trigger button when mode="button". */ + buttonClassName?: string; + /** When true, show the "Send to" destination address row in the quote + * summary. Defaults to false. */ + showDestinationAddress?: boolean; + actionButtonText?: string; + /** Default amount (in USD) seeded into the wallet flow once the user + * picks a source token. Defaults to $1. Set to 0 to disable seeding. */ + defaultAmountUsd?: number; + /** The deposit funding methods to offer, e.g. `['wallet','deposit_address']`. + * Acts as an allow-list: only listed methods can appear (a method also still + * needs its own runtime condition). Defaults to all available methods. */ + methods?: DepositMethodId[]; +}; + /** * Props the CDN remote's widget export accepts — the shared shape forwarded by * the vanilla `mountWidget` and the React `LayerswapWidget`. @@ -138,3 +198,22 @@ export type WidgetProps< */ wagmiConfig?: TWagmi; }; + +/** + * Props the CDN remote's DEPOSIT widget export accepts — the shared shape + * forwarded by the vanilla `mountDepositWidget` and the React + * `LayerswapDepositWidget`: the common widget props plus the deposit-specific + * configuration, flattened to mirror `Deposit`'s props in `@layerswap/widget`. + * + * Same generic scheme as {@link WidgetProps}: framework-agnostic by default, + * bound to precise host/remote types by `@layerswap/widget-react` and the CDN + * remote respectively. + */ +export type DepositWidgetProps< + TWagmi = never, + TLoading = never, + TConfig = WidgetConfig, + TWalletDefaults = WalletDefaults, + TCallbacks = WidgetCallbacks, +> = WidgetProps & + DepositConfig; diff --git a/packages/widget/types/src/index.ts b/packages/widget/types/src/index.ts index b022efed2e..053239aadc 100644 --- a/packages/widget/types/src/index.ts +++ b/packages/widget/types/src/index.ts @@ -6,4 +6,8 @@ export type { WidgetProps, WalletDefaults, WalletProviderId, + DepositWidgetProps, + DepositConfig, + DepositMethodId, + SupportedDestination, } from './config'; From fe62121b8c17f9ee3f3c4d8a2fe6fcff761ef9de Mon Sep 17 00:00:00 2001 From: babkenmes Date: Tue, 4 Aug 2026 20:14:20 +0400 Subject: [PATCH 12/24] feat(widget-cdn): approvable cleanup of old builds, Azure-only delivery, environment-parameterized deploys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup mechanism (plan → approve → apply): - scripts/cleanup-lib.mjs: reference-aware retention planner — keeps promoted, unexpired, N-most-recent, and grace-period builds; mark-and- sweeps the shared /assets/ namespace against retained manifests (never age-only — chunks are shared across builds); deletes manifest-first and aborts if a channel pointer changes between planning and deletion - scripts/cleanup-azure.mjs: CLI (dry run by default, --apply to delete, --plan-out/--plan-in for the human-approval round-trip: apply deletes exactly the reviewed plan intersected with fresh state) - azure-lib.mjs: paginated listObjects (Blob REST) + idempotent deleteObject - widget-cdn-cleanup.yml: plan job posts the keep/delete report and uploads the plan; apply waits on environment required-reviewer approval and is skipped when nothing is deletable; weekly schedule doubles as the channel-expiry watchdog (warns <7 days before the live manifest expires) Azure is the only CDN path — remove Cloudflare R2/Worker: - delete deploy-r2/rollback-r2/r2-lib/cleanup-r2, worker/, the R2 deploy workflow, and @aws-sdk/client-s3 + wrangler + workers-types deps - loader docs/CSP examples now reference the Azure Blob origin Environment-parameterized deploys (production is drop-in): - widget-cdn-azure-sandbox.yml → widget-cdn-deploy.yml with an environment input; Azure identity comes from environment variables and the signing key from the environment secret (one keypair per environment) - .github/scripts/resolve-widget-cdn-env.sh: sandbox identifiers as inline fallbacks so the sandbox works before any environment is configured; production fails with a clear missing-variables error until an admin fills in widget-cdn-production; production deploys refused off the default branch; deploys pause on environment approval once required reviewers are configured Co-Authored-By: Claude Fable 5 --- .github/scripts/resolve-widget-cdn-env.sh | 57 + .../workflows/widget-cdn-azure-sandbox.yml | 130 -- .github/workflows/widget-cdn-cleanup.yml | 193 +++ .github/workflows/widget-cdn-deploy.yml | 300 ++--- apps/widget-cdn/AZURE_SETUP.md | 10 +- apps/widget-cdn/README.md | 216 ++-- apps/widget-cdn/package.json | 10 +- apps/widget-cdn/rspack.config.mjs | 10 +- apps/widget-cdn/scripts/azure-lib.mjs | 57 +- .../scripts/azure-release-scripts.test.mjs | 17 +- apps/widget-cdn/scripts/build-id.mjs | 14 +- apps/widget-cdn/scripts/build-manifest.mjs | 4 +- apps/widget-cdn/scripts/cleanup-azure.mjs | 64 + apps/widget-cdn/scripts/cleanup-lib.mjs | 458 +++++++ apps/widget-cdn/scripts/cleanup.test.mjs | 434 +++++++ apps/widget-cdn/scripts/deploy-azure.mjs | 2 +- apps/widget-cdn/scripts/deploy-r2.mjs | 211 ---- apps/widget-cdn/scripts/r2-lib.mjs | 114 -- .../scripts/release-scripts.test.mjs | 241 ---- apps/widget-cdn/scripts/rollback-r2.mjs | 106 -- apps/widget-cdn/worker/src/index.ts | 151 --- apps/widget-cdn/worker/tsconfig.json | 14 - apps/widget-cdn/worker/wrangler.toml | 23 - packages/widget/js/src/manifest.ts | 12 +- packages/widget/react/README.md | 6 +- pnpm-lock.yaml | 1115 +++-------------- 26 files changed, 1669 insertions(+), 2300 deletions(-) create mode 100755 .github/scripts/resolve-widget-cdn-env.sh delete mode 100644 .github/workflows/widget-cdn-azure-sandbox.yml create mode 100644 .github/workflows/widget-cdn-cleanup.yml create mode 100644 apps/widget-cdn/scripts/cleanup-azure.mjs create mode 100644 apps/widget-cdn/scripts/cleanup-lib.mjs create mode 100644 apps/widget-cdn/scripts/cleanup.test.mjs delete mode 100644 apps/widget-cdn/scripts/deploy-r2.mjs delete mode 100644 apps/widget-cdn/scripts/r2-lib.mjs delete mode 100644 apps/widget-cdn/scripts/release-scripts.test.mjs delete mode 100644 apps/widget-cdn/scripts/rollback-r2.mjs delete mode 100644 apps/widget-cdn/worker/src/index.ts delete mode 100644 apps/widget-cdn/worker/tsconfig.json delete mode 100644 apps/widget-cdn/worker/wrangler.toml diff --git a/.github/scripts/resolve-widget-cdn-env.sh b/.github/scripts/resolve-widget-cdn-env.sh new file mode 100755 index 0000000000..b427df6992 --- /dev/null +++ b/.github/scripts/resolve-widget-cdn-env.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Resolve the widget-cdn Azure target for a GitHub environment. +# +# Inputs (env): +# DEPLOY_TARGET GitHub environment name (required) +# RAW_AZURE_CLIENT_ID \ +# RAW_AZURE_TENANT_ID | the environment's `vars.*`, passed through by +# RAW_AZURE_SUBSCRIPTION_ID | the workflow (may be empty) +# RAW_AZURE_STORAGE_ACCOUNT | +# RAW_AZURE_STORAGE_CONTAINER +# RAW_CDN_BASE_URL / +# +# For widget-cdn-azure-sandbox, missing values fall back to the personal +# sandbox identifiers (they are identifiers, not credentials) so the sandbox +# works before its environment variables exist. Any other environment must be +# fully configured in Settings → Environments, or this fails with a clear +# error. Resolved values are appended to $GITHUB_ENV. +set -euo pipefail + +TARGET="${DEPLOY_TARGET:?DEPLOY_TARGET is required}" +CLIENT_ID="${RAW_AZURE_CLIENT_ID:-}" +TENANT_ID="${RAW_AZURE_TENANT_ID:-}" +SUBSCRIPTION_ID="${RAW_AZURE_SUBSCRIPTION_ID:-}" +STORAGE_ACCOUNT="${RAW_AZURE_STORAGE_ACCOUNT:-}" +CONTAINER="${RAW_AZURE_STORAGE_CONTAINER:-}" +BASE_URL="${RAW_CDN_BASE_URL:-}" + +if [ "$TARGET" = "widget-cdn-azure-sandbox" ]; then + CLIENT_ID="${CLIENT_ID:-69ca1ebc-8381-4b6d-864a-497053bdd827}" + TENANT_ID="${TENANT_ID:-8e8e42bf-64ee-4057-99ab-8288126d4ebb}" + SUBSCRIPTION_ID="${SUBSCRIPTION_ID:-f60908b5-2c18-438c-89c5-510a01d2a802}" + STORAGE_ACCOUNT="${STORAGE_ACCOUNT:-layerswapcdntest}" +else + MISSING="" + [ -z "$CLIENT_ID" ] && MISSING="$MISSING AZURE_CLIENT_ID" + [ -z "$TENANT_ID" ] && MISSING="$MISSING AZURE_TENANT_ID" + [ -z "$SUBSCRIPTION_ID" ] && MISSING="$MISSING AZURE_SUBSCRIPTION_ID" + [ -z "$STORAGE_ACCOUNT" ] && MISSING="$MISSING AZURE_STORAGE_ACCOUNT" + if [ -n "$MISSING" ]; then + echo "::error::Environment '$TARGET' is missing variables:$MISSING — configure them in Settings → Environments." + exit 1 + fi +fi + +CONTAINER="${CONTAINER:-widget-cdn}" +BASE_URL="${BASE_URL:-https://${STORAGE_ACCOUNT}.blob.core.windows.net/${CONTAINER}}" + +{ + echo "AZURE_CLIENT_ID=$CLIENT_ID" + echo "AZURE_TENANT_ID=$TENANT_ID" + echo "AZURE_SUBSCRIPTION_ID=$SUBSCRIPTION_ID" + echo "AZURE_STORAGE_ACCOUNT=$STORAGE_ACCOUNT" + echo "AZURE_STORAGE_CONTAINER=$CONTAINER" + echo "CDN_BASE_URL=${BASE_URL%/}" +} >> "$GITHUB_ENV" + +echo "Resolved target $TARGET (account: $STORAGE_ACCOUNT, container: $CONTAINER)" diff --git a/.github/workflows/widget-cdn-azure-sandbox.yml b/.github/workflows/widget-cdn-azure-sandbox.yml deleted file mode 100644 index 9cd505fe1f..0000000000 --- a/.github/workflows/widget-cdn-azure-sandbox.yml +++ /dev/null @@ -1,130 +0,0 @@ -name: widget-cdn — Azure sandbox deploy - -# Branch-only personal-account validation. Production remains on the R2 -# workflow until the production Azure identity and migration compatibility -# are ready. -on: - push: - branches: - - codex/widget-cdn-azure-sandbox - workflow_dispatch: - inputs: - promote: - description: "Publish this build to the rolling v1 channel" - type: boolean - required: false - default: true - -concurrency: - group: widget-cdn-azure-sandbox - cancel-in-progress: false - -permissions: - contents: read - id-token: write - -jobs: - deploy: - runs-on: ubuntu-latest - env: - # These identify the isolated personal Azure sandbox; they are not - # credentials. Authentication is a short-lived, branch-scoped OIDC token. - AZURE_CLIENT_ID: 69ca1ebc-8381-4b6d-864a-497053bdd827 - AZURE_TENANT_ID: 8e8e42bf-64ee-4057-99ab-8288126d4ebb - AZURE_SUBSCRIPTION_ID: f60908b5-2c18-438c-89c5-510a01d2a802 - AZURE_STORAGE_ACCOUNT: layerswapcdntest - AZURE_STORAGE_CONTAINER: widget-cdn - LAYERSWAP_PROMOTE: ${{ github.event_name != 'workflow_dispatch' || inputs.promote != false }} - - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - uses: pnpm/action-setup@v6 - - - uses: actions/setup-node@v5 - with: - node-version: 20 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build workspace packages - run: pnpm build:packages - - - name: Test widget release tooling - run: | - pnpm --filter @layerswap/widget-cdn test - pnpm --filter @layerswap/widget-js test - pnpm --filter @layerswap/widget-react check:types - - - name: Compute build metadata - run: | - echo "LAYERSWAP_GIT_SHA=${GITHUB_SHA}" >> "$GITHUB_ENV" - echo "LAYERSWAP_BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV" - BUILD_ID=$(node --input-type=module -e "import { resolveBuildIdentity } from './apps/widget-cdn/scripts/build-id.mjs'; process.stdout.write(resolveBuildIdentity('./apps/widget-cdn').buildId)") - echo "LAYERSWAP_BUILD_ID=${BUILD_ID}" >> "$GITHUB_ENV" - - # Use the same stable signing identity as the published loader. Rotating - # this key during a normal deploy would break every existing installation. - - name: Materialize manifest signing key - id: key - env: - PRIVATE_KEY_PEM: ${{ secrets.LAYERSWAP_PRIVATE_KEY_PEM }} - run: | - if [ -z "$PRIVATE_KEY_PEM" ]; then - echo "::error::LAYERSWAP_PRIVATE_KEY_PEM is not configured" - exit 1 - fi - umask 077 - KEY_PATH="$RUNNER_TEMP/layerswap-azure-test-signing.pem" - printf '%s' "$PRIVATE_KEY_PEM" > "$KEY_PATH" - echo "key-path=$KEY_PATH" >> "$GITHUB_OUTPUT" - - - name: Build signed sandbox artifact - working-directory: apps/widget-cdn - env: - LAYERSWAP_PRIVATE_KEY_PEM: ${{ steps.key.outputs.key-path }} - run: | - pnpm build - pnpm verify-manifest - - - name: Log in to personal Azure with OIDC - uses: azure/login@v2 - with: - client-id: ${{ env.AZURE_CLIENT_ID }} - tenant-id: ${{ env.AZURE_TENANT_ID }} - subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} - - - name: Upload to Azure Blob Storage and promote channel - working-directory: apps/widget-cdn - run: pnpm deploy:azure - - - name: Wipe sandbox signing key - if: always() - run: rm -f "$RUNNER_TEMP/layerswap-azure-test-signing.pem" - - - name: Smoke-test the public Blob channel - if: ${{ env.LAYERSWAP_PROMOTE != 'false' }} - run: | - set -euo pipefail - BASE_URL="https://${AZURE_STORAGE_ACCOUNT}.blob.core.windows.net/${AZURE_STORAGE_CONTAINER}" - MANIFEST_URL="${BASE_URL}/v1/manifest.json" - curl -fsSL -H "Origin: https://example.com" -D /tmp/headers \ - "$MANIFEST_URL" -o /tmp/manifest.json - grep -i 'access-control-allow-origin' /tmp/headers \ - || { echo "::error::Azure Blob CORS header is missing"; exit 1; } - node -e " - const m = JSON.parse(require('fs').readFileSync('/tmp/manifest.json','utf8')); - if (!m.signature) throw new Error('manifest has no sandbox signature'); - if (m.buildId !== '${LAYERSWAP_BUILD_ID}') { - throw new Error('channel has ' + m.buildId + ', expected ${LAYERSWAP_BUILD_ID}'); - } - const remote = new URL(m.remoteEntry, '${MANIFEST_URL}').href; - if (!remote.includes('/${LAYERSWAP_BUILD_ID}/remoteEntry.js')) { - throw new Error('remoteEntry is not immutable: ' + remote); - } - console.log('Azure channel OK', { buildId: m.buildId, remote }); - " diff --git a/.github/workflows/widget-cdn-cleanup.yml b/.github/workflows/widget-cdn-cleanup.yml new file mode 100644 index 0000000000..da1c8d513f --- /dev/null +++ b/.github/workflows/widget-cdn-cleanup.yml @@ -0,0 +1,193 @@ +name: widget-cdn — cleanup old builds + +# Reference-aware retention for the widget CDN storage in Azure Blob Storage +# (policy in apps/widget-cdn/scripts/cleanup-lib.mjs), run as +# plan → approve → apply: +# +# 1. The `plan` job computes what is deletable, prints the keep/delete +# report to the run summary, and uploads the plan as an artifact. It also +# warns when the promoted build's manifest is near expiry (the live +# channel fails closed past it). +# 2. The `apply` job waits on the target ENVIRONMENT's required reviewers — +# the "we can delete these 4 old builds, delete them?" button. Review the +# plan job's summary, then approve. It deletes exactly the approved plan +# (intersected with fresh state, so anything promoted or uploaded since +# planning stays protected). If the plan is empty, apply is skipped and +# no approval is requested. +# +# The target is a GitHub environment carrying the Azure identity as variables +# (see .github/scripts/resolve-widget-cdn-env.sh; the sandbox has inline +# fallbacks, production must be configured in Settings → Environments). Both +# jobs declare the environment — if it has required reviewers, the read-only +# plan needs one approval and the delete a second, informed one. +# +# Note: `schedule` only fires from the repository's default branch; until this +# file lands there, run it via workflow_dispatch (pick the branch — the Azure +# sandbox OIDC credential is scoped to codex/widget-cdn-azure-sandbox). + +on: + schedule: + - cron: "17 4 * * 1" # weekly sandbox plan; deletes only if approved + workflow_dispatch: + inputs: + environment: + description: "Storage target (GitHub environment)" + type: choice + options: [widget-cdn-azure-sandbox, widget-cdn-production] + default: widget-cdn-azure-sandbox + keep_recent: + description: "Most recent builds kept per channel regardless of expiry" + type: string + required: false + default: "3" + asset_grace_days: + description: "Grace period before unreferenced shared assets are deleted" + type: string + required: false + default: "2" + +concurrency: + group: widget-cdn-cleanup-${{ inputs.environment || 'widget-cdn-azure-sandbox' }} + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +env: + DEPLOY_TARGET: ${{ inputs.environment || 'widget-cdn-azure-sandbox' }} + KEEP_RECENT: ${{ inputs.keep_recent || '3' }} + ASSET_GRACE_DAYS: ${{ inputs.asset_grace_days || '2' }} + +jobs: + plan: + runs-on: ubuntu-latest + environment: ${{ inputs.environment || 'widget-cdn-azure-sandbox' }} + outputs: + has-deletions: ${{ steps.plan.outputs.has-deletions }} + + steps: + - uses: actions/checkout@v5 + + - name: Resolve storage target + env: + RAW_AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + RAW_AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + RAW_AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} + RAW_AZURE_STORAGE_ACCOUNT: ${{ vars.AZURE_STORAGE_ACCOUNT }} + RAW_AZURE_STORAGE_CONTAINER: ${{ vars.AZURE_STORAGE_CONTAINER }} + RAW_CDN_BASE_URL: ${{ vars.CDN_BASE_URL }} + run: bash .github/scripts/resolve-widget-cdn-env.sh + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v5 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build shared widget protocol + run: pnpm --filter @layerswap/widget-types build + + - name: Log in to Azure with OIDC + uses: azure/login@v2 + with: + client-id: ${{ env.AZURE_CLIENT_ID }} + tenant-id: ${{ env.AZURE_TENANT_ID }} + subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} + + - name: Compute cleanup plan (dry run) + id: plan + working-directory: apps/widget-cdn + run: | + set -euo pipefail + node scripts/cleanup-azure.mjs \ + --keep-recent "$KEEP_RECENT" --asset-grace-days "$ASSET_GRACE_DAYS" \ + --plan-out "$RUNNER_TEMP/plan.json" 2>&1 | tee "$RUNNER_TEMP/cleanup.log" + COUNT=$(node -e "const p=require(process.env.RUNNER_TEMP+'/plan.json'); console.log(p.deleteBuilds.length + p.deleteAssets.length)") + echo "has-deletions=$([ "$COUNT" -gt 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + { + echo "## widget-cdn cleanup plan (${DEPLOY_TARGET})" + echo "" + if [ "$COUNT" -gt 0 ]; then + echo "**$COUNT object group(s) are deletable. Approve the \`apply\` job to delete them.**" + else + echo "Nothing to delete — apply will be skipped." + fi + echo "" + echo '```' + cat "$RUNNER_TEMP/cleanup.log" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload plan artifact + uses: actions/upload-artifact@v4 + with: + name: cleanup-plan + path: ${{ runner.temp }}/plan.json + retention-days: 7 + + apply: + needs: plan + if: ${{ needs.plan.outputs.has-deletions == 'true' }} + runs-on: ubuntu-latest + # The approval gate: this environment's required reviewers must approve + # before anything is deleted from the container. + environment: ${{ inputs.environment || 'widget-cdn-azure-sandbox' }} + + steps: + - uses: actions/checkout@v5 + + - name: Resolve storage target + env: + RAW_AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + RAW_AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + RAW_AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} + RAW_AZURE_STORAGE_ACCOUNT: ${{ vars.AZURE_STORAGE_ACCOUNT }} + RAW_AZURE_STORAGE_CONTAINER: ${{ vars.AZURE_STORAGE_CONTAINER }} + RAW_CDN_BASE_URL: ${{ vars.CDN_BASE_URL }} + run: bash .github/scripts/resolve-widget-cdn-env.sh + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v5 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build shared widget protocol + run: pnpm --filter @layerswap/widget-types build + + - name: Download approved plan + uses: actions/download-artifact@v4 + with: + name: cleanup-plan + path: ${{ runner.temp }} + + - name: Log in to Azure with OIDC + uses: azure/login@v2 + with: + client-id: ${{ env.AZURE_CLIENT_ID }} + tenant-id: ${{ env.AZURE_TENANT_ID }} + subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} + + - name: Apply approved plan + working-directory: apps/widget-cdn + run: | + set -euo pipefail + node scripts/cleanup-azure.mjs --apply --plan-in "$RUNNER_TEMP/plan.json" \ + --keep-recent "$KEEP_RECENT" --asset-grace-days "$ASSET_GRACE_DAYS" \ + 2>&1 | tee "$RUNNER_TEMP/cleanup.log" + { + echo "## widget-cdn cleanup applied (${DEPLOY_TARGET})" + echo "" + echo '```' + cat "$RUNNER_TEMP/cleanup.log" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/widget-cdn-deploy.yml b/.github/workflows/widget-cdn-deploy.yml index 69691c7f5d..c1da1275dd 100644 --- a/.github/workflows/widget-cdn-deploy.yml +++ b/.github/workflows/widget-cdn-deploy.yml @@ -1,81 +1,87 @@ -name: widget-cdn — build, sign, deploy - -# Production builds of `apps/widget-cdn` go through CI so the signing key never -# lives outside `secrets.LAYERSWAP_PRIVATE_KEY_PEM`. The build is uploaded to -# Cloudflare R2 under an immutable per-build prefix and the rolling channel -# pointer (`channels.json`) can be promoted to it in a separate dispatch. The Cloudflare Worker in -# `apps/widget-cdn/worker` serves R2 and 302-redirects `/vN/*` to the current -# build. This workflow is the only path to a production CDN deploy. +name: widget-cdn — deploy + +# Azure Blob Storage is the only CDN deploy path. The target is a GitHub +# ENVIRONMENT (Settings → Environments), which carries the Azure identity as +# environment VARIABLES and the manifest signing key as an environment SECRET: +# +# variables: AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID, +# AZURE_STORAGE_ACCOUNT, AZURE_STORAGE_CONTAINER (optional, +# default widget-cdn), CDN_BASE_URL (optional, for a custom +# domain / Front Door; defaults to the Blob endpoint) +# secrets: LAYERSWAP_PRIVATE_KEY_PEM (each environment gets its OWN +# keypair — the production key must never exist at repo level) +# +# - widget-cdn-azure-sandbox: the personal-account sandbox. Its identifiers +# (not credentials) are hardcoded as fallbacks below, so it works before +# the environment has any variables configured. +# - widget-cdn-production: drop-in — fails with a clear error until a repo +# admin creates the production Azure identity/storage account and fills in +# the environment. Dispatches only from the default branch. +# +# A push deploys to the sandbox. Production is workflow_dispatch with +# environment=widget-cdn-production; add required reviewers to that +# environment so the deploy pauses for approval ("Review deployments"). +# +# When this branch moves to the default branch, update the push trigger below. on: push: - # `babkenmes/widget-cdn-delivery` is temporary — for testing the full - # deploy pipeline on the feature branch before merging. Remove it (leaving - # just `main`) once the CDN is verified end-to-end. - branches: [main, babkenmes/widget-cdn-delivery] - paths: - - "apps/widget-cdn/**" - - "packages/widget/**" - - "packages/wallets/**" - - "pnpm-lock.yaml" - - "pnpm-workspace.yaml" - - ".github/workflows/widget-cdn-deploy.yml" + branches: + - codex/widget-cdn-azure-sandbox workflow_dispatch: inputs: - promote_build_id: - description: "Existing immutable buildId to promote; leave empty to build/upload a candidate" - type: string - required: false - default: "" - promote_channel: - description: "Compatibility channel for promote_build_id" - type: string - required: false - default: "v1" - deploy_worker: - description: "Also deploy the Cloudflare Worker (edge router)" + environment: + description: "Deployment target (GitHub environment)" + type: choice + options: [widget-cdn-azure-sandbox, widget-cdn-production] + default: widget-cdn-azure-sandbox + promote: + description: "Publish this build to the rolling v1 channel" type: boolean required: false - default: false + default: true concurrency: - group: widget-cdn-${{ github.ref }} + group: widget-cdn-deploy-${{ inputs.environment || 'widget-cdn-azure-sandbox' }} cancel-in-progress: false permissions: contents: read - # Reserved for an OIDC upgrade path if the signing key moves into KMS later. id-token: write - # Lets the failure alert open/comment a GitHub issue. - issues: write jobs: deploy: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.promote_build_id == '' }} runs-on: ubuntu-latest - environment: widget-cdn-production + # Required reviewers on this environment turn every deploy into an + # approvable step; the cleanup workflow's apply job shares the same gate. + environment: ${{ inputs.environment || 'widget-cdn-azure-sandbox' }} env: - # R2 credentials (an R2 API token scoped to the bucket) + endpoint. - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_BUCKET: ${{ vars.R2_BUCKET || 'layerswap-widget-cdn' }} - # Upload and promotion are deliberately separate. Main pushes publish an - # immutable candidate; a later dispatch promotes its existing buildId. - LAYERSWAP_PROMOTE: "false" - # Published builds are immutable, but the buildId embeds the commit sha - # (see apps/widget-cdn/scripts/build-id.mjs), so every push gets a fresh - # prefix — only literally re-deploying the SAME commit collides. For that - # rare case, uncomment the next line. Re-comment afterwards. - # ALLOW_OVERWRITE: '1' + DEPLOY_TARGET: ${{ inputs.environment || 'widget-cdn-azure-sandbox' }} + LAYERSWAP_PROMOTE: ${{ github.event_name != 'workflow_dispatch' || inputs.promote != false }} steps: - uses: actions/checkout@v5 with: fetch-depth: 0 - # No `version:` — let the action read it from package.json's - # `packageManager` field, so CI tracks the repo's pinned pnpm exactly. + - name: Resolve deployment target + env: + RAW_AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + RAW_AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + RAW_AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} + RAW_AZURE_STORAGE_ACCOUNT: ${{ vars.AZURE_STORAGE_ACCOUNT }} + RAW_AZURE_STORAGE_CONTAINER: ${{ vars.AZURE_STORAGE_CONTAINER }} + RAW_CDN_BASE_URL: ${{ vars.CDN_BASE_URL }} + run: | + set -euo pipefail + # Production must ship code that has landed on the default branch. + if [ "$DEPLOY_TARGET" = "widget-cdn-production" ] \ + && [ "$GITHUB_REF_NAME" != "${{ github.event.repository.default_branch }}" ]; then + echo "::error::Refusing to deploy '$GITHUB_REF_NAME' to production — dispatch from '${{ github.event.repository.default_branch }}'." + exit 1 + fi + bash .github/scripts/resolve-widget-cdn-env.sh + - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v5 @@ -89,17 +95,12 @@ jobs: - name: Build workspace packages run: pnpm build:packages - # Exercise the release-critical build identity, deploy/rollback, SRI, - # public type, and Worker contracts before the signing key is staged or - # any artifact can be uploaded to production. - name: Test widget release tooling run: | pnpm --filter @layerswap/widget-cdn test pnpm --filter @layerswap/widget-js test pnpm --filter @layerswap/widget-react check:types - pnpm exec tsc -p apps/widget-cdn/worker/tsconfig.json --noEmit - # Build provenance stamped into the signed manifest. - name: Compute build metadata run: | echo "LAYERSWAP_GIT_SHA=${GITHUB_SHA}" >> "$GITHUB_ENV" @@ -107,181 +108,76 @@ jobs: BUILD_ID=$(node --input-type=module -e "import { resolveBuildIdentity } from './apps/widget-cdn/scripts/build-id.mjs'; process.stdout.write(resolveBuildIdentity('./apps/widget-cdn').buildId)") echo "LAYERSWAP_BUILD_ID=${BUILD_ID}" >> "$GITHUB_ENV" - # Stage the signing key to a tempfile (not argv/env-dump visible). Wiped - # at job end regardless of outcome. - - name: Stage signing key + # The signing key is the target environment's own secret. Rotating a key + # during a normal deploy would break every existing installation pinned + # to its public half. + - name: Materialize manifest signing key id: key env: - PEM: ${{ secrets.LAYERSWAP_PRIVATE_KEY_PEM }} + PRIVATE_KEY_PEM: ${{ secrets.LAYERSWAP_PRIVATE_KEY_PEM }} run: | - if [ -z "$PEM" ]; then - echo "::error::secrets.LAYERSWAP_PRIVATE_KEY_PEM is not set." + if [ -z "$PRIVATE_KEY_PEM" ]; then + echo "::error::LAYERSWAP_PRIVATE_KEY_PEM is not configured for environment '$DEPLOY_TARGET'" exit 1 fi umask 077 KEY_PATH="$RUNNER_TEMP/layerswap-signing.pem" - printf '%s\n' "$PEM" > "$KEY_PATH" + printf '%s' "$PRIVATE_KEY_PEM" > "$KEY_PATH" echo "key-path=$KEY_PATH" >> "$GITHUB_OUTPUT" - - name: Build + sign widget-cdn + - name: Build signed artifact working-directory: apps/widget-cdn env: LAYERSWAP_PRIVATE_KEY_PEM: ${{ steps.key.outputs.key-path }} - run: pnpm build + run: | + pnpm build + pnpm verify-manifest - # Round-trip the signature against the public key bundled into the loader - # core. Fails the deploy on a key mismatch before anything ships. - - name: Verify signature against bundled public key - working-directory: apps/widget-cdn - run: pnpm verify-manifest + - name: Log in to Azure with OIDC + uses: azure/login@v2 + with: + client-id: ${{ env.AZURE_CLIENT_ID }} + tenant-id: ${{ env.AZURE_TENANT_ID }} + subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} - # Upload the immutable candidate without touching a rolling channel. - # Refuses to overwrite an already-published - # buildId, so re-running a deploy for the same commit is a no-op error - # rather than a silent clobber — any new commit gets a fresh buildId. - - name: Upload candidate to R2 + - name: Upload to Azure Blob Storage and promote channel working-directory: apps/widget-cdn - run: pnpm deploy:r2 + run: pnpm deploy:azure - name: Wipe signing key from runner if: always() run: rm -f "$RUNNER_TEMP/layerswap-signing.pem" - # Optional: deploy the edge Worker. Only needed when worker code changes; - # off by default on push, opt-in via workflow_dispatch. - - name: Deploy Cloudflare Worker - if: ${{ github.event_name == 'workflow_dispatch' && inputs.deploy_worker == true }} - working-directory: apps/widget-cdn - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} - run: pnpm worker:deploy - - # End-to-end check against the immutable candidate before it is eligible - # for promotion. - - name: Smoke-test the uploaded candidate - if: ${{ vars.CDN_BASE_URL != '' }} + - name: Smoke-test the public Blob channel + if: ${{ env.LAYERSWAP_PROMOTE != 'false' }} run: | set -euo pipefail - VERSION=$(node -e "console.log(require('./apps/widget-cdn/node_modules/@layerswap/widget/package.json').version)") - MANIFEST_URL="${{ vars.CDN_BASE_URL }}/${LAYERSWAP_BUILD_ID}/manifest.json" - echo "Fetching $MANIFEST_URL" - curl -fsSL -L -H "Origin: https://example.com" -D /tmp/headers "$MANIFEST_URL" -o /tmp/manifest.json - grep -i 'access-control-allow-origin' /tmp/headers || { echo "::error::CDN missing CORS header"; exit 1; } + MANIFEST_URL="${CDN_BASE_URL}/v1/manifest.json" + curl -fsSL -H "Origin: https://example.com" -D /tmp/headers \ + "$MANIFEST_URL" -o /tmp/manifest.json + grep -i 'access-control-allow-origin' /tmp/headers \ + || { echo "::error::Azure Blob CORS header is missing"; exit 1; } node -e " const m = JSON.parse(require('fs').readFileSync('/tmp/manifest.json','utf8')); - if (!m.signature) { console.error('live manifest has no signature'); process.exit(1); } - if (m.killSwitch) { console.error('live manifest has killSwitch=true'); process.exit(1); } - if (m.buildId !== '${LAYERSWAP_BUILD_ID}') { console.error('candidate has build', m.buildId, '— expected ${LAYERSWAP_BUILD_ID}'); process.exit(1); } - if (m.version !== '${VERSION}') { console.error('candidate has version', m.version, '— expected ${VERSION}'); process.exit(1); } - if (m.gitSha !== '${GITHUB_SHA}') { console.error('candidate has commit', m.gitSha, '— expected ${GITHUB_SHA}'); process.exit(1); } - console.log('candidate OK buildId=', m.buildId, ' version=', m.version, ' gitSha=', m.gitSha); + if (!m.signature) throw new Error('manifest has no signature'); + if (m.buildId !== '${LAYERSWAP_BUILD_ID}') { + throw new Error('channel has ' + m.buildId + ', expected ${LAYERSWAP_BUILD_ID}'); + } + const remote = new URL(m.remoteEntry, '${MANIFEST_URL}').href; + if (!remote.includes('/${LAYERSWAP_BUILD_ID}/remoteEntry.js')) { + throw new Error('remoteEntry is not immutable: ' + remote); + } + console.log('Azure channel OK', { buildId: m.buildId, remote }); " - name: Summary if: always() run: | - VERSION=$(node -e "console.log(require('./apps/widget-cdn/node_modules/@layerswap/widget/package.json').version)" 2>/dev/null || echo "?") - BUILD_ID=${LAYERSWAP_BUILD_ID:-?} { echo "## widget-cdn deploy" echo "" - echo "- Build id: \`${BUILD_ID}\`" - echo "- Version: \`${VERSION}\`" - echo "- Channel promoted: \`false\` (candidate only)" + echo "- Target: \`${DEPLOY_TARGET}\`" + echo "- Build id: \`${LAYERSWAP_BUILD_ID:-?}\`" + echo "- Promoted: \`${LAYERSWAP_PROMOTE}\`" echo "- Commit: \`${GITHUB_SHA}\`" } >> "$GITHUB_STEP_SUMMARY" - - # A failed deploy means the live channel silently keeps serving the - # previous build — that must never be discovered by noticing stale - # behavior in the browser. Open a GitHub issue @mentioning the pusher - # (triggers a GitHub notification/email); if an open deploy-failure - # issue already exists, comment on it instead of piling up duplicates. - - name: Alert on failure - if: failure() - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - VERSION=$(node -p "require('./packages/widget/core/package.json').version" 2>/dev/null || echo "?") - RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - TITLE="widget-cdn deploy failed" - { - echo "@${{ github.actor }} the widget-cdn deploy failed — the live CDN channel is still serving the **previous** build." - echo "" - echo "- Version: \`${VERSION}\`" - echo "- Branch: \`${GITHUB_REF_NAME}\` @ \`${GITHUB_SHA::7}\`" - echo "- Run: ${RUN_URL}" - echo "" - echo "Check the run log for the failing step. (Build ids embed the commit sha, so an immutability collision only happens when re-deploying the same commit — re-run from a new commit or set \`ALLOW_OVERWRITE=1\`.)" - } > "$RUNNER_TEMP/alert-body.md" - EXISTING=$(gh issue list --repo "${{ github.repository }}" --state open \ - --search "\"${TITLE}\" in:title" --json number --jq '.[0].number // empty') - if [ -n "$EXISTING" ]; then - gh issue comment "$EXISTING" --repo "${{ github.repository }}" --body-file "$RUNNER_TEMP/alert-body.md" - else - gh issue create --repo "${{ github.repository }}" --title "$TITLE" --body-file "$RUNNER_TEMP/alert-body.md" - fi - - promote: - if: ${{ github.event_name == 'workflow_dispatch' && inputs.promote_build_id != '' }} - runs-on: ubuntu-latest - environment: widget-cdn-production - env: - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_BUCKET: ${{ vars.R2_BUCKET || 'layerswap-widget-cdn' }} - PROMOTE_BUILD_ID: ${{ inputs.promote_build_id }} - PROMOTE_CHANNEL: ${{ inputs.promote_channel }} - - steps: - - uses: actions/checkout@v5 - - - uses: pnpm/action-setup@v6 - - - uses: actions/setup-node@v5 - with: - node-version: 20 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build shared widget protocol - run: pnpm --filter @layerswap/widget-types build - - # rollback-r2 is also the promotion primitive: it validates that the - # immutable target exists, belongs to the requested protocol channel, - # and has a usable validity window before changing channels.json. - - name: Promote existing candidate - working-directory: apps/widget-cdn - run: node scripts/rollback-r2.mjs "$PROMOTE_CHANNEL" "$PROMOTE_BUILD_ID" - - - name: Smoke-test promoted channel - if: ${{ vars.CDN_BASE_URL != '' }} - run: | - set -euo pipefail - MANIFEST_URL="${{ vars.CDN_BASE_URL }}/${PROMOTE_CHANNEL}/manifest.json" - curl -fsSL -L -H "Origin: https://example.com" -D /tmp/headers "$MANIFEST_URL" -o /tmp/manifest.json - grep -i 'access-control-allow-origin' /tmp/headers || { echo "::error::CDN missing CORS header"; exit 1; } - node -e " - const m = JSON.parse(require('fs').readFileSync('/tmp/manifest.json','utf8')); - const expectedProtocol = Number(process.env.PROMOTE_CHANNEL.slice(1)); - const actualProtocol = m.protocolMajor ?? Number(String(m.channel).slice(1)); - if (!m.signature) { console.error('live manifest has no signature'); process.exit(1); } - if (m.killSwitch) { console.error('live manifest has killSwitch=true'); process.exit(1); } - if (m.buildId !== process.env.PROMOTE_BUILD_ID) { console.error('live channel has', m.buildId, '— expected', process.env.PROMOTE_BUILD_ID); process.exit(1); } - if (actualProtocol !== expectedProtocol) { console.error('live protocol is', actualProtocol, '— expected', expectedProtocol); process.exit(1); } - console.log('promotion OK channel=', process.env.PROMOTE_CHANNEL, ' buildId=', m.buildId); - " - - - name: Summary - if: always() - run: | - { - echo "## widget-cdn promotion" - echo "" - echo "- Channel: \`${PROMOTE_CHANNEL}\`" - echo "- Build id: \`${PROMOTE_BUILD_ID}\`" - } >> "$GITHUB_STEP_SUMMARY" diff --git a/apps/widget-cdn/AZURE_SETUP.md b/apps/widget-cdn/AZURE_SETUP.md index 74f8296f17..231e5a4c24 100644 --- a/apps/widget-cdn/AZURE_SETUP.md +++ b/apps/widget-cdn/AZURE_SETUP.md @@ -182,9 +182,11 @@ host environment variables or globals to bypass that boundary. ## 10. GitHub Actions branch sandbox -After local delivery works, `.github/workflows/widget-cdn-azure-sandbox.yml` -deploys this feature branch to the personal account through OIDC. It does not -require a GitHub environment or stored Azure/signing secret. +After local delivery works, `.github/workflows/widget-cdn-deploy.yml` deploys +this feature branch to the personal account through OIDC (its default target +is the `widget-cdn-azure-sandbox` GitHub environment; the sandbox identifiers +are inline fallbacks, so only the `LAYERSWAP_PRIVATE_KEY_PEM` signing secret +is required). In Azure Portal: @@ -220,5 +222,3 @@ Front Door and preferably a private Blob origin. The final rollout also needs: - GitHub Actions OIDC instead of a developer's Azure CLI session. - The production manifest signing key/KMS. - A stable custom domain such as `cdn.layerswap.io`. -- A transition redirect for loaders that still contain the old `workers.dev` - URL. diff --git a/apps/widget-cdn/README.md b/apps/widget-cdn/README.md index 4182dc3ef2..f12c450cd8 100644 --- a/apps/widget-cdn/README.md +++ b/apps/widget-cdn/README.md @@ -1,7 +1,7 @@ # @layerswap/widget-cdn Rspack Module-Federation **remote** that exposes `@layerswap/widget` for -runtime delivery, plus the Cloudflare **Worker + R2** edge that serves it. +runtime delivery from **Azure Blob Storage**. - `name`: `layerswap_widget` - `filename`: `remoteEntry.js` @@ -13,32 +13,30 @@ runtime delivery, plus the Cloudflare **Worker + R2** edge that serves it. Consumed by `@layerswap/widget-react` (React hosts) and `@layerswap/widget-js` (framework-agnostic hosts). -Azure Blob Storage migration is being developed side-by-side with the current -production R2 path. See [AZURE_SETUP.md](./AZURE_SETUP.md) for the personal -sandbox setup and `deploy:azure` / `rollback:azure` commands. +See [AZURE_SETUP.md](./AZURE_SETUP.md) for the storage-account setup and the +production follow-up checklist. ## Versioning model -Every build is published to an **immutable, buildId-named prefix** in R2 and -never overwritten. A single mutable pointer (`channels.json`) maps each rolling -major channel to its current build, and the Worker turns that into a redirect. +Every build is published to an **immutable, buildId-named prefix** and never +overwritten. Each rolling major channel is a single mutable blob — the signed +manifest copied to `//manifest.json`: ``` -R2 bucket (layerswap-widget-cdn) +Blob container (widget-cdn) ├── 1.5.0-abc123def456/ ← immutable build, write-once │ ├── manifest.json ← signed; describes this exact build │ └── remoteEntry.js ├── 1.5.0-fedcba654321/ ← next build, also immutable ├── assets/ ← shared content-addressed namespace │ └── ..js ← byte-identical chunks reuse one URL/object -└── channels.json ← the ONLY mutable object: { "v1": "1.5.0-abc123def456" } +└── v1/manifest.json ← the ONLY mutable object: the rolling channel + pointer (a copy of the promoted build's manifest) ``` -The Worker serves two kinds of URL: - | URL | Behavior | |---|---| -| `…/v1/manifest.json` | **Rolling** — Worker 302-redirects to the current `v1` build. Auto-updates within ~60s of a channel flip. | +| `…/v1/manifest.json` | **Rolling** — `Cache-Control: no-store`, so new page loads observe a promotion immediately. | | `…/1.5.0-abc123def456/manifest.json` | **Immutable** — exact build used for staging, rollback, and debugging. Its signed validity window still expires. | Integrators don't choose between them: the manifest URL is not a public knob. @@ -50,12 +48,12 @@ automatically. Immutable URLs exist for Layerswap's own release mechanics cannot point the loader at them. The manifest uses a build-addressed relative `remoteEntry` (for example -`../1.5.0-abc123def456/remoteEntry.js`), so the remote anchors at the immutable -build path whether the manifest arrives through the Worker's redirect or from -an Azure rolling-channel blob. The remote loads its content-hashed chunks from -the stable `/assets/` namespace, and the signed manifest registers SRI for both -locations. **Rollback / roll-forward is a pointer flip** — no rebuild, no -re-upload (see `scripts/rollback-r2.mjs` or `scripts/rollback-azure.mjs`). +`../1.5.0-abc123def456/remoteEntry.js`), so it resolves to the same immutable +build path whether it is fetched from the immutable location or from the +rolling channel blob. The remote loads its content-hashed chunks from the +stable `/assets/` namespace, and the signed manifest registers SRI for both +locations. **Rollback / roll-forward is a pointer copy of an already-signed +manifest** — no rebuild, no re-upload (see `scripts/rollback-azure.mjs`). A build's immutable identity is its **buildId** — the `@layerswap/widget` version plus the git sha (`1.5.0-abc123def456`, see `scripts/build-id.mjs`) — @@ -78,7 +76,7 @@ Serves the remote on `http://127.0.0.1:3100/remoteEntry.js`, plus an unsigned intentionally fixed to the signed production channel and cannot load this unsigned development manifest. -Dev output stays flat in `dist/` (no version directory, no redirect). +Dev output stays flat in `dist/` (no version directory). ## Production build @@ -104,101 +102,128 @@ one-off build. pnpm verify-manifest # round-trip the signature against the bundled public key ``` -## The edge: Cloudflare Worker + R2 - -The Worker (`worker/`) serves R2 and does the rolling-channel redirect: - -- `GET /vN/` → reads `channels.json`, 302-redirects to `//` - (short cache so flips propagate fast). -- `GET //` → serves from R2 with `immutable` caching + permissive - CORS (chunks load `crossorigin="anonymous"` for SRI). -- `GET /assets/` → serves the shared immutable chunk used - by every build that emitted the same bytes. -- Security headers (HSTS, nosniff, frame-deny) on every response. - -```bash -pnpm worker:dev # local Worker dev -pnpm worker:deploy # wrangler deploy -``` - ## Deploy +Requires `az login` (or CI OIDC) plus `AZURE_STORAGE_ACCOUNT` / +`AZURE_STORAGE_CONTAINER` — see `scripts/azure-lib.mjs`. No account key or +connection string is used. + ```bash -pnpm deploy:r2 # local default: upload and promote -LAYERSWAP_PROMOTE=false pnpm deploy:r2 # upload only (staged release) -ALLOW_OVERWRITE=1 pnpm deploy:r2 # re-upload an existing build (escape hatch) +pnpm deploy:azure # local default: upload and promote +LAYERSWAP_PROMOTE=false pnpm deploy:azure # upload only (staged release) +ALLOW_OVERWRITE=1 pnpm deploy:azure # re-upload an existing build (escape hatch) # roll a channel to any already-published build (instant; no rebuild): -node scripts/rollback-r2.mjs v1 1.4.0-abc123def456 +node scripts/rollback-azure.mjs v1 1.4.0-abc123def456 ``` -`deploy:r2` refuses to overwrite an already-published buildId — published +`deploy:azure` refuses to overwrite an already-published buildId — published builds are immutable. It reuses content-hashed objects already present under `/assets/`, so unchanged chunks keep their browser cache and do not consume -duplicate R2 storage. The buildId embeds the commit sha, so deploying from any +duplicate storage. The buildId embeds the commit sha, so deploying from any new commit gets a fresh control-file prefix. -### CI deploy (production) - -Production deploys go through `.github/workflows/widget-cdn-deploy.yml`. The -signing key never leaves CI. A main push builds, signs, verifies, uploads, and -smoke-tests an immutable candidate without changing a channel. Promote it in a -separate `workflow_dispatch` run by entering the reported `buildId` and its -channel; that job validates the existing target, flips the pointer, and -smoke-tests the live channel. The Worker is deployed separately and rarely -changes. - -### Azure Blob cache and retention policy - -Azure promotion overwrites only `//manifest.json`. It is published -with `Cache-Control: no-store, max-age=0` so new page loads observe a pointer +### CI deploy + +Deploys go through `.github/workflows/widget-cdn-deploy.yml`: build, test, +sign (key never leaves CI), verify, upload, promote, then smoke-test the +public channel. The deploy target is a **GitHub environment**: + +- **`widget-cdn-azure-sandbox`** — the personal-account sandbox. A push + deploys here; its Azure identifiers are hardcoded fallbacks in + `.github/scripts/resolve-widget-cdn-env.sh`, so it works before the + environment has any variables configured. +- **`widget-cdn-production`** — drop-in: selectable via `workflow_dispatch` + today, fails with a clear "missing variables" error until a repo admin + creates the production Azure identity/storage account and fills in the + environment. Production dispatches are refused from non-default branches. + +Each environment carries its Azure identity as environment **variables** +(`AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID`, +`AZURE_STORAGE_ACCOUNT`, optional `AZURE_STORAGE_CONTAINER` and +`CDN_BASE_URL` for a custom domain) and its **own** signing keypair as the +environment **secret** `LAYERSWAP_PRIVATE_KEY_PEM` — the production key must +never exist at repo level. Add required reviewers to an environment and every +deploy (and cleanup apply) to it pauses on "Review deployments". + +### Blob cache and retention policy + +Promotion overwrites only `//manifest.json`. It is published with +`Cache-Control: no-store, max-age=0` so new page loads observe a pointer change without a CDN purge. Immutable build controls and content-addressed assets remain `public, max-age=31536000, immutable`. -If Azure Front Door or another company CDN sits in front of Blob Storage, its -route for `*/manifest.json` must honor the origin header or have caching -disabled. Build and asset routes should keep long-lived caching. +If Azure Front Door or another CDN sits in front of Blob Storage, its route +for `*/manifest.json` must honor the origin header or have caching disabled. +Build and asset routes should keep long-lived caching. + +### Cleanup of old builds Do not apply an age-only delete policy to `/assets/`: chunks are shared between -builds. Cleanup must retain every current supported-major build plus rollback -targets, mark the assets referenced by their manifests, and delete only -unreferenced assets after a grace period. Azure lifecycle rules are suitable -for old channel-blob versions and unpromoted candidates; reference-aware build -cleanup should be a separate scheduled job once production retention periods -are chosen. +builds. Reference-aware cleanup lives in `scripts/cleanup-azure.mjs` (policy in +`scripts/cleanup-lib.mjs`): -#### Required GitHub secrets +```bash +pnpm cleanup:azure # dry run — report only +pnpm cleanup:azure --plan-out plan.json # dry run + reviewable plan file +pnpm cleanup:azure --apply # delete everything deletable +pnpm cleanup:azure --apply --plan-in plan.json # delete only the approved plan +pnpm cleanup:azure --keep-recent 5 --asset-grace-days 2 --build-grace-days 2 +``` -| Secret | Value | -|---|---| -| `LAYERSWAP_PRIVATE_KEY_PEM` | ECDSA P-256 private key (PEM). Its public half is baked into `packages/widget/js/src/manifest.ts`. | -| `R2_ACCOUNT_ID` | Cloudflare account id. | -| `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` | R2 **Account** API token (Object Read & Write) credentials. | -| `CLOUDFLARE_API_TOKEN` | *(optional)* Only if CI deploys the Worker. | +A build prefix is **retained** when it is the current target of any rolling +channel, its manifest is not yet expired (unexpired builds are valid rollback +targets and in-flight sessions may still lazy-load their chunks), it is one of +the `--keep-recent` most recent builds of its channel, or it has no manifest +but was uploaded within `--build-grace-days` (an in-flight deploy publishes +its manifest last). Everything else is deleted — manifest first, so an +interrupted deletion can never leave a promotable half-build. Shared assets +are then mark-and-swept: chunks referenced by any retained manifest survive; +unreferenced ones are deleted after `--asset-grace-days`. Channel pointers and +unrecognized keys are never touched, anomalies are kept and warned about, and +apply aborts if a channel pointer changes between planning and deletion. + +The cleanup run also warns when a promoted build's manifest is expired or +expires within 7 days — the early signal that the live channel is about to +fail closed. + +CI runs (`.github/workflows/widget-cdn-cleanup.yml`) are **plan → approve → +apply**: a `plan` job posts the keep/delete report to the run summary and +saves it with `--plan-out`; the `apply` job waits on the +`widget-cdn-azure-sandbox` environment approval ("Review deployments") and +then deletes exactly the reviewed plan via `--apply --plan-in` — recomputed +against fresh state, so anything promoted or uploaded since planning stays +protected, and items that became deletable after approval wait for the next +plan. When nothing is deletable, apply is skipped and no approval is +requested. **Approvals only exist once a repo admin adds required reviewers** +in Settings → Environments → `widget-cdn-azure-sandbox`. + +#### Required GitHub configuration (per environment) + +| Kind | Name | Value | +|---|---|---| +| secret | `LAYERSWAP_PRIVATE_KEY_PEM` | ECDSA P-256 private key (PEM), one keypair per environment. The production public half is baked into `packages/widget/js/src/manifest.ts`. | +| variable | `AZURE_CLIENT_ID` / `AZURE_TENANT_ID` / `AZURE_SUBSCRIPTION_ID` | OIDC-federated identity for `azure/login`. | +| variable | `AZURE_STORAGE_ACCOUNT` | Target storage account. | +| variable | `AZURE_STORAGE_CONTAINER` | Optional; default `widget-cdn`. | +| variable | `CDN_BASE_URL` | Optional; custom domain / Front Door origin for smoke tests. Defaults to the Blob endpoint. | + +Azure authentication uses OIDC federation (`azure/login`) — no storage keys or +connection strings are stored as secrets. -#### Required GitHub variables +### First-time infrastructure setup -| Variable | Value | -|---|---| -| `R2_BUCKET` | Bucket name (default `layerswap-widget-cdn`). | -| `CDN_BASE_URL` | Public CDN origin, e.g. the Worker's `*.workers.dev` URL or custom domain. Used by the smoke test. | +Follow [AZURE_SETUP.md](./AZURE_SETUP.md) (storage account, container, CORS, +RBAC, OIDC federation). The signing keypair is generated once: -### First-time infrastructure setup +```bash +openssl ecparam -name prime256v1 -genkey -noout -out .keys/manifest-private.pem +openssl ec -in .keys/manifest-private.pem -pubout -outform DER | base64 | tr -d '\n' +``` -1. **Enable R2** in the Cloudflare dashboard (Storage & databases → R2). -2. **Create the bucket**: `wrangler r2 bucket create layerswap-widget-cdn`. -3. **Deploy the Worker**: `pnpm worker:deploy` (registers a `*.workers.dev` - subdomain on first run, or wire a custom domain in `worker/wrangler.toml`). -4. **Create an R2 Account API token** (Object Read & Write) → set - `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` / `R2_ACCOUNT_ID`. -5. **Generate the signing keypair** (one-time): - ```bash - openssl ecparam -name prime256v1 -genkey -noout -out .keys/manifest-private.pem - openssl ec -in .keys/manifest-private.pem -pubout -outform DER | base64 | tr -d '\n' - ``` - Put the base64 SPKI into `packages/widget/js/src/manifest.ts` - (`MANIFEST_VERIFY_PUBLIC_KEY_SPKI_B64`) and `.keys/manifest-public.b64.txt`, - and the private PEM into the `LAYERSWAP_PRIVATE_KEY_PEM` secret. +Put the base64 SPKI into `packages/widget/js/src/manifest.ts` +(`MANIFEST_VERIFY_PUBLIC_KEY_SPKI_B64`) and `.keys/manifest-public.b64.txt`, +and the private PEM into the `LAYERSWAP_PRIVATE_KEY_PEM` secret. ### Key rotation @@ -214,6 +239,7 @@ The public key constant in `@layerswap/widget-js` is the trust anchor. Rotate: ### Upgrading to KMS The current workflow trusts a GitHub Secret to hold the private key. The secure -upgrade keeps the key non-extractable in a KMS/HSM and signs via its API. The -workflow already requests `id-token: write` for OIDC federation. The loader -never changes — it only sees signed bytes, never the key. +upgrade keeps the key non-extractable in a KMS/HSM (e.g. Azure Key Vault) and +signs via its API. The workflow already requests `id-token: write` for OIDC +federation. The loader never changes — it only sees signed bytes, never the +key. diff --git a/apps/widget-cdn/package.json b/apps/widget-cdn/package.json index d3585788b2..e8ee82ee56 100644 --- a/apps/widget-cdn/package.json +++ b/apps/widget-cdn/package.json @@ -7,13 +7,10 @@ "dev": "rspack serve --config rspack.config.mjs", "build": "node scripts/prepare-build.mjs && rspack build --config rspack.config.mjs --mode production && node scripts/build-manifest.mjs && node scripts/check-bundle-budget.mjs", "verify-manifest": "node scripts/verify-manifest.mjs", - "deploy:r2": "node scripts/deploy-r2.mjs", - "rollback:r2": "node scripts/rollback-r2.mjs", "deploy:azure": "node scripts/deploy-azure.mjs", "rollback:azure": "node scripts/rollback-azure.mjs", + "cleanup:azure": "node scripts/cleanup-azure.mjs", "test": "pnpm --filter @layerswap/widget-types build && node --test scripts/*.test.mjs", - "worker:dev": "wrangler dev --config worker/wrangler.toml", - "worker:deploy": "wrangler deploy --config worker/wrangler.toml", "start": "node serve.mjs", "check:bundle-budget": "node scripts/check-bundle-budget.mjs" }, @@ -43,8 +40,6 @@ "zustand": "catalog:" }, "devDependencies": { - "@aws-sdk/client-s3": "^3.700.0", - "@cloudflare/workers-types": "^4.20241127.0", "@module-federation/enhanced": "^0.9.0", "@rspack/cli": "^1.1.5", "@rspack/core": "^1.1.5", @@ -53,7 +48,6 @@ "css-loader": "^7.1.2", "mini-css-extract-plugin": "^2.9.2", "style-loader": "^4.0.0", - "typescript": "^5.9.2", - "wrangler": "^4.0.0" + "typescript": "^5.9.2" } } diff --git a/apps/widget-cdn/rspack.config.mjs b/apps/widget-cdn/rspack.config.mjs index c4baeb0bfa..32673bc9f3 100644 --- a/apps/widget-cdn/rspack.config.mjs +++ b/apps/widget-cdn/rspack.config.mjs @@ -44,12 +44,12 @@ const SHARED_SINGLETONS = { // Every build publishes its control files to an IMMUTABLE, buildId-named // directory (`dist/1.7.0-abc123def456/`). Content-hashed chunks are uploaded // separately under the stable `/assets/` namespace so byte-identical chunks -// keep the same URL across builds. The rolling major channel (`/v1/`) is a 302 -// redirect served by the Cloudflare Worker (see `worker/`). +// keep the same URL across builds. The rolling major channel (`/v1/`) is the +// signed manifest copied to `//manifest.json` at promotion. // // The buildId (widget version + git sha — see `scripts/build-id.mjs`) is // resolved identically by `build-manifest.mjs`, `verify-manifest.mjs`, and -// `deploy-r2.mjs`, so all four agree on the output directory. +// `deploy-azure.mjs`, so all four agree on the output directory. const { buildId: BUILD_ID } = resolveBuildIdentity(__dirname); // Dev-only: emit a minimal `manifest.json` next to `remoteEntry.js` so the @@ -91,8 +91,8 @@ export default (env, argv) => { devtool: isProd ? 'source-map' : 'eval-cheap-module-source-map', entry: {}, // Pure remote — no app entry. output: { - // Production: dist//* — deploy-r2.mjs keeps control files under - // that prefix and publishes content-hashed files under /assets/. + // Production: dist//* — deploy-azure.mjs keeps control files + // under that prefix and publishes content-hashed files under /assets/. // Dev: keep dist/ flat (the dev-server serves whatever publicPath says). path: path.resolve(__dirname, isProd ? `dist/${BUILD_ID}` : 'dist'), publicPath: 'auto', diff --git a/apps/widget-cdn/scripts/azure-lib.mjs b/apps/widget-cdn/scripts/azure-lib.mjs index 442b1022c0..a86d29e3b2 100644 --- a/apps/widget-cdn/scripts/azure-lib.mjs +++ b/apps/widget-cdn/scripts/azure-lib.mjs @@ -79,12 +79,16 @@ function objectUrl(ctx, key) { return `${ctx.endpoint}/${container}/${encodedKey}`; } -async function blobRequest(ctx, key, init = {}) { +async function authorizedFetch(ctx, url, init = {}) { const headers = new Headers(init.headers); headers.set("Authorization", `Bearer ${ctx.accessToken}`); headers.set("x-ms-date", new Date().toUTCString()); headers.set("x-ms-version", "2023-11-03"); - return fetch(objectUrl(ctx, key), { ...init, headers }); + return fetch(url, { ...init, headers }); +} + +async function blobRequest(ctx, key, init = {}) { + return authorizedFetch(ctx, objectUrl(ctx, key), init); } async function throwResponseError(operation, key, response) { @@ -95,6 +99,55 @@ async function throwResponseError(operation, key, response) { ); } +// The List Blobs response is XML; only and are needed, +// so a scoped regex parse avoids pulling in an XML dependency. +function decodeXml(text) { + return text + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&"); +} + +export async function listObjects(ctx, { prefix } = {}) { + const results = []; + let marker; + do { + const params = new URLSearchParams({ restype: "container", comp: "list" }); + if (prefix) params.set("prefix", prefix); + if (marker) params.set("marker", marker); + const url = `${ctx.endpoint}/${encodeURIComponent(ctx.containerName)}?${params}`; + const response = await authorizedFetch(ctx, url); + if (!response.ok) { + await throwResponseError("LIST", prefix ?? "(container)", response); + } + const xml = await response.text(); + for (const blob of xml.match(/[\s\S]*?<\/Blob>/g) ?? []) { + const name = blob.match(/([\s\S]*?)<\/Name>/)?.[1]; + if (!name) continue; + const lastModified = blob.match( + /([\s\S]*?)<\/Last-Modified>/, + )?.[1]; + results.push({ + key: decodeXml(name), + lastModified: lastModified ? new Date(lastModified) : undefined, + }); + } + const nextMarker = xml.match(/([\s\S]*?)<\/NextMarker>/)?.[1]; + marker = nextMarker ? decodeXml(nextMarker) : undefined; + } while (marker); + return results; +} + +export async function deleteObject(ctx, key) { + const response = await blobRequest(ctx, key, { method: "DELETE" }); + // Idempotent: an already-deleted blob is not an error for cleanup. + if (response.status === 404) return false; + if (!response.ok) await throwResponseError("DELETE", key, response); + return true; +} + export async function objectExists(ctx, key) { const response = await blobRequest(ctx, key, { method: "HEAD" }); if (response.status === 404) return false; diff --git a/apps/widget-cdn/scripts/azure-release-scripts.test.mjs b/apps/widget-cdn/scripts/azure-release-scripts.test.mjs index ed6202a7f3..b5dfe17847 100644 --- a/apps/widget-cdn/scripts/azure-release-scripts.test.mjs +++ b/apps/widget-cdn/scripts/azure-release-scripts.test.mjs @@ -6,10 +6,25 @@ import test from "node:test"; import { putObject, writeChannelManifest } from "./azure-lib.mjs"; import { deployAzureBuild } from "./deploy-azure.mjs"; import { rollbackAzureChannel } from "./rollback-azure.mjs"; -import { ASSET_BASE, remoteEntryForBuild } from "./cdn-layout.mjs"; +import { + ASSET_BASE, + deploymentKey, + remoteEntryForBuild, +} from "./cdn-layout.mjs"; const silentLogger = { log() {}, warn() {} }; +test("content-hashed chunks use the shared asset namespace", () => { + assert.equal( + deploymentKey("1.7.0-0123456789ab", "837.0123456789abcdef.js"), + "assets/837.0123456789abcdef.js", + ); + assert.equal( + deploymentKey("1.7.0-0123456789ab", "remoteEntry.js"), + "1.7.0-0123456789ab/remoteEntry.js", + ); +}); + test("Azure Blob PUT uses Entra auth, encoded keys, and blob HTTP properties", async (t) => { const originalFetch = globalThis.fetch; t.after(() => { diff --git a/apps/widget-cdn/scripts/build-id.mjs b/apps/widget-cdn/scripts/build-id.mjs index d478188aca..1855625a23 100644 --- a/apps/widget-cdn/scripts/build-id.mjs +++ b/apps/widget-cdn/scripts/build-id.mjs @@ -1,19 +1,19 @@ // Single source of truth for the build's identity, shared by rspack.config.mjs, -// build-manifest.mjs, verify-manifest.mjs, and deploy-r2.mjs so the output -// directory, R2 prefix, and manifest fields can never disagree. +// build-manifest.mjs, verify-manifest.mjs, and deploy-azure.mjs so the output +// directory, storage prefix, and manifest fields can never disagree. // // Two distinct concepts, deliberately kept separate: // // - `buildId` — the IMMUTABLE identity of one build: `-` // (or `LAYERSWAP_RELEASE_ID` verbatim). Names the dist directory and the -// write-once R2 prefix. The deploy workflow also triggers on changes to -// widget-cdn/wallets/widget-js/widget-react, all of which change the +// write-once storage prefix. The deploy workflow also triggers on changes +// to widget-cdn/wallets/widget-js/widget-react, all of which change the // deployed bytes WITHOUT bumping `@layerswap/widget` — keying immutability // on the version alone made every such deploy collide with the previously // published prefix. // - `channel` — the rolling COMPATIBILITY pointer (`v`). -// The Worker 302-redirects `/v1/*` to whatever buildId -// `channels.json` currently maps it to. +// Promotion copies the signed manifest of the current buildId to +// `//manifest.json`. // // `version` stays the `@layerswap/widget` implementation version stamped into // the manifest. It does not select a CDN channel. @@ -24,7 +24,7 @@ import { WIDGET_PROTOCOL_MAJOR } from '@layerswap/widget-types'; const VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; // `assets` is the shared content-addressed namespace and cannot also name a -// build directory. Rolling channel names are reserved for Worker redirects. +// build directory. Rolling channel names are reserved for channel pointers. export const BUILD_ID_RE = /^(?!v\d+$)(?!assets$)[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/; export function isValidBuildId(value) { diff --git a/apps/widget-cdn/scripts/build-manifest.mjs b/apps/widget-cdn/scripts/build-manifest.mjs index d2e67c3ea5..ff6873191a 100644 --- a/apps/widget-cdn/scripts/build-manifest.mjs +++ b/apps/widget-cdn/scripts/build-manifest.mjs @@ -57,8 +57,8 @@ if (!existsSync(ASSET_DIST)) { // This resolves to the immutable build prefix from BOTH publication paths: // //manifest.json (immutable) and //manifest.json (rolling). -// Cloudflare's existing redirect still lands at the same URL, while Azure can -// promote by copying this signed manifest directly to the rolling path. +// Promotion copies this signed manifest directly to the rolling path, and the +// relative remoteEntry anchors at the immutable build either way. const remoteEntry = remoteEntryForBuild(buildId); // Hash every JS file in the build directory and record under the diff --git a/apps/widget-cdn/scripts/cleanup-azure.mjs b/apps/widget-cdn/scripts/cleanup-azure.mjs new file mode 100644 index 0000000000..6eadb8e3e0 --- /dev/null +++ b/apps/widget-cdn/scripts/cleanup-azure.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +// Reference-aware cleanup of old widget builds in Azure Blob Storage. +// +// node scripts/cleanup-azure.mjs # dry run (report only) +// node scripts/cleanup-azure.mjs --plan-out plan.json # dry run + reviewable plan file +// node scripts/cleanup-azure.mjs --apply # delete everything deletable +// node scripts/cleanup-azure.mjs --apply --plan-in plan.json # delete only the approved plan +// node scripts/cleanup-azure.mjs --keep-recent 5 --asset-grace-days 2 --build-grace-days 2 +// +// Retention rules live in cleanup-lib.mjs. Requires `az login` (or the CI +// OIDC login) and AZURE_STORAGE_ACCOUNT / AZURE_STORAGE_CONTAINER. + +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + deleteObject, + listObjects, + makeClient, + readJsonObject, +} from "./azure-lib.mjs"; +import { isValidBuildId } from "./build-id.mjs"; +import { runCleanupCli } from "./cleanup-lib.mjs"; + +const CHANNEL_MANIFEST_RE = /^v\d+\/manifest\.json$/; + +// Azure has no channels.json — each rolling pointer is the signed manifest +// copied to //manifest.json. Its buildId IS the pointer. +export async function readAzureChannelPointers( + ctx, + { list = listObjects, readJson = readJsonObject } = {}, +) { + const pointers = {}; + for (const { key } of await list(ctx, { prefix: "v" })) { + if (!CHANNEL_MANIFEST_RE.test(key)) continue; + const manifest = await readJson(ctx, key); + if (manifest && isValidBuildId(manifest.buildId)) { + pointers[key.split("/")[0]] = manifest.buildId; + } + } + return pointers; +} + +export const azureCleanupOps = { + listObjects, + readJson: readJsonObject, + readChannelPointers: readAzureChannelPointers, + deleteObject, +}; + +const entryUrl = process.argv[1] + ? pathToFileURL(resolve(process.argv[1])).href + : undefined; +if (entryUrl === import.meta.url) { + try { + await runCleanupCli({ + argv: process.argv.slice(2), + ctx: makeClient(), + ops: azureCleanupOps, + }); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + } +} diff --git a/apps/widget-cdn/scripts/cleanup-lib.mjs b/apps/widget-cdn/scripts/cleanup-lib.mjs new file mode 100644 index 0000000000..0c77c6adcd --- /dev/null +++ b/apps/widget-cdn/scripts/cleanup-lib.mjs @@ -0,0 +1,458 @@ +// Reference-aware cleanup for the widget CDN storage in Azure Blob Storage +// (immutable // prefixes, shared content-hashed /assets/, and the +// mutable rolling //manifest.json pointer). +// +// Deletion policy — a build prefix is RETAINED when any of these hold: +// - it is the current target of any rolling channel pointer; +// - its signed manifest has not expired (unexpired builds are valid rollback +// targets, and in-flight browser sessions may still lazy-load their +// chunks); +// - it is one of the N most recent builds of its channel (rollback depth +// even when everything has expired); +// - it has no readable manifest but was uploaded recently (a deploy in +// flight publishes the manifest LAST — the grace period keeps cleanup +// from racing it). +// Everything else is deleted, manifest.json first, so a half-deleted build +// can never be promoted by rollback scripts (they require the manifest). +// +// Shared assets are mark-and-swept: the union of `chunks` referenced by every +// RETAINED manifest is protected; unreferenced assets are deleted only after +// their own grace period (again to avoid racing an in-flight deploy that +// uploads assets before its manifest). NEVER apply an age-only policy to +// /assets/ — chunks are shared between builds. +// +// Anything anomalous (unknown top-level keys, a manifest whose buildId does +// not match its prefix, a channel pointing at a missing build) is kept and +// surfaced as a warning — cleanup only deletes what it fully understands. + +import { readFileSync, writeFileSync } from 'node:fs'; +import { isValidBuildId } from './build-id.mjs'; +import { ASSET_DIRECTORY, isSharedAsset } from './cdn-layout.mjs'; + +const DAY_MS = 24 * 60 * 60 * 1000; +// Must cover the loader's clock-skew allowance (5 min) with margin. +const EXPIRY_SKEW_MS = 60 * 60 * 1000; +const CHANNEL_EXPIRY_WARN_MS = 7 * DAY_MS; +const CHANNEL_RE = /^v\d+$/; + +export const DEFAULT_MIN_RECENT_PER_CHANNEL = 3; +export const DEFAULT_ASSET_GRACE_MS = 2 * DAY_MS; +export const DEFAULT_ORPHAN_GRACE_MS = 2 * DAY_MS; + +function toMs(value) { + if (value == null) return undefined; + const ms = value instanceof Date ? value.getTime() : Date.parse(value); + return Number.isNaN(ms) ? undefined : ms; +} + +/** + * Split a full object listing into build prefixes, shared assets, rolling + * channel objects, and everything else. Channel objects and unknown keys are + * never deletion candidates. + */ +export function partitionObjects(objects) { + const builds = new Map(); + const assets = []; + const channelObjects = []; + const unknown = []; + for (const object of objects) { + const segments = object.key.split('/'); + const [first, ...rest] = segments; + if (CHANNEL_RE.test(first)) { + channelObjects.push(object); + continue; + } + if (first === ASSET_DIRECTORY) { + if (rest.length === 1 && isSharedAsset(rest[0])) { + assets.push({ ...object, name: rest[0] }); + } else { + unknown.push(object); + } + continue; + } + if (rest.length > 0 && isValidBuildId(first)) { + if (!builds.has(first)) builds.set(first, []); + builds.get(first).push(object); + continue; + } + unknown.push(object); + } + return { builds, assets, channelObjects, unknown }; +} + +/** + * Pure retention planner. Inputs: + * now epoch ms + * channels { v1: '', … } — current rolling pointers + * builds [{ buildId, keys: [{ key, lastModified }], manifest|null }] + * assets [{ key, name, lastModified }] + */ +export function planCleanup({ + now, + channels, + builds, + assets, + minRecentPerChannel = DEFAULT_MIN_RECENT_PER_CHANNEL, + assetGraceMs = DEFAULT_ASSET_GRACE_MS, + orphanGraceMs = DEFAULT_ORPHAN_GRACE_MS, +}) { + if (!Number.isFinite(now)) throw new Error('[cleanup] planCleanup requires `now`'); + const warnings = []; + + const promotedChannels = new Map(); + for (const [channel, buildId] of Object.entries(channels)) { + if (!builds.some((b) => b.buildId === buildId)) { + warnings.push( + `channel ${channel} points at ${buildId}, which has no published build prefix`, + ); + } + const list = promotedChannels.get(buildId) ?? []; + list.push(channel); + promotedChannels.set(buildId, list); + } + + const entries = builds.map((build) => { + const newestUpload = Math.max( + 0, + ...build.keys.map((k) => toMs(k.lastModified) ?? 0), + ); + return { + ...build, + expiresMs: toMs(build.manifest?.expiresAt), + recency: toMs(build.manifest?.issuedAt) ?? newestUpload, + newestUpload, + reasons: [], + }; + }); + + // N most recent builds per channel survive even past manifest expiry. + const byChannel = new Map(); + for (const entry of entries) { + if (!entry.manifest) continue; + const channel = + typeof entry.manifest.channel === 'string' ? entry.manifest.channel : '(unknown)'; + const list = byChannel.get(channel) ?? []; + list.push(entry); + byChannel.set(channel, list); + } + for (const list of byChannel.values()) { + list.sort((a, b) => b.recency - a.recency); + for (const entry of list.slice(0, minRecentPerChannel)) { + entry.reasons.push('recent'); + } + } + + for (const entry of entries) { + const liveChannels = promotedChannels.get(entry.buildId); + if (liveChannels) { + entry.reasons.push(...liveChannels.map((c) => `promoted:${c}`)); + if (!entry.manifest) { + warnings.push(`promoted build ${entry.buildId} has no readable manifest`); + } else if (entry.expiresMs === undefined) { + warnings.push( + `promoted build ${entry.buildId} has no valid expiresAt — verifying loaders reject it`, + ); + } else if (entry.expiresMs <= now) { + warnings.push( + `promoted build ${entry.buildId} EXPIRED at ${entry.manifest.expiresAt} — ` + + 'verifying loaders are failing closed; re-publish now', + ); + } else if (entry.expiresMs - now < CHANNEL_EXPIRY_WARN_MS) { + warnings.push( + `promoted build ${entry.buildId} expires ${entry.manifest.expiresAt} ` + + '(<7 days) — deploy or re-sign before the channel goes dark', + ); + } + } + + if (entry.manifest) { + if (entry.manifest.buildId !== entry.buildId) { + warnings.push( + `build prefix ${entry.buildId} contains a manifest claiming to be ` + + `${JSON.stringify(entry.manifest.buildId)} — kept, investigate manually`, + ); + entry.reasons.push('manifest-mismatch'); + } + if (entry.expiresMs !== undefined && entry.expiresMs + EXPIRY_SKEW_MS > now) { + entry.reasons.push('unexpired'); + } + } else if (now - entry.newestUpload < orphanGraceMs) { + // Possibly a deploy in flight: its manifest publishes last. + entry.reasons.push('upload-grace'); + } + } + + const keepBuilds = entries.filter((e) => e.reasons.length > 0); + const deleteBuilds = entries + .filter((e) => e.reasons.length === 0) + .map((e) => ({ + buildId: e.buildId, + keys: e.keys.map((k) => k.key), + reason: e.manifest + ? 'expired, not promoted, not recent' + : 'no manifest (incomplete upload) past grace period', + })); + + // Mark-and-sweep the shared asset namespace against retained manifests. + const referenced = new Set(); + for (const entry of keepBuilds) { + for (const name of Object.keys(entry.manifest?.chunks ?? {})) { + if (isSharedAsset(name)) referenced.add(name); + } + } + const deleteAssets = []; + let referencedAssets = 0; + let gracedAssets = 0; + for (const asset of assets) { + if (referenced.has(asset.name)) { + referencedAssets += 1; + continue; + } + const age = now - (toMs(asset.lastModified) ?? now); + if (age < assetGraceMs) { + gracedAssets += 1; + continue; + } + deleteAssets.push(asset.key); + } + + return { + keepBuilds: keepBuilds.map((e) => ({ buildId: e.buildId, reasons: e.reasons })), + deleteBuilds, + deleteAssets, + referencedAssets, + gracedAssets, + warnings, + }; +} + +async function runWithConcurrency(items, limit, task) { + if (items.length === 0) return; + const workerCount = Math.min(Math.max(1, Math.floor(limit)), items.length); + let cursor = 0; + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (cursor < items.length) { + const item = items[cursor]; + cursor += 1; + await task(item); + } + }), + ); +} + +function normalizedPointers(channels) { + return JSON.stringify( + Object.fromEntries(Object.entries(channels).sort(([a], [b]) => a.localeCompare(b))), + ); +} + +/** + * Full cleanup pass over one storage backend. Dry-run unless `apply` is true. + * + * ops = { + * listObjects(ctx) -> [{ key, lastModified }] + * readJson(ctx, key) -> object | undefined + * readChannelPointers(ctx) -> { [channel]: buildId } + * deleteObject(ctx, key) + * } + * + * `approvedPlan` (a previously written plan file) restricts deletion to the + * items a human approved: the fresh plan is recomputed from live state, and + * only its intersection with the approved plan is deleted. Items that became + * deletable after approval are deferred; approved items that are no longer + * deletable stay protected. + */ +export async function runCleanup({ + ctx, + ops, + apply = false, + approvedPlan, + now = Date.now(), + logger = console, + minRecentPerChannel, + assetGraceMs, + orphanGraceMs, + deleteConcurrency = 8, +}) { + const objects = await ops.listObjects(ctx); + const { builds: buildMap, assets, unknown } = partitionObjects(objects); + const channels = await ops.readChannelPointers(ctx); + + const builds = []; + for (const [buildId, keys] of buildMap) { + const manifestKey = `${buildId}/manifest.json`; + const hasManifest = keys.some((k) => k.key === manifestKey); + const manifest = hasManifest ? await ops.readJson(ctx, manifestKey) : undefined; + builds.push({ buildId, keys, manifest: manifest ?? null }); + } + + const plan = planCleanup({ + now, + channels, + builds, + assets, + ...(minRecentPerChannel !== undefined ? { minRecentPerChannel } : {}), + ...(assetGraceMs !== undefined ? { assetGraceMs } : {}), + ...(orphanGraceMs !== undefined ? { orphanGraceMs } : {}), + }); + + if (approvedPlan) { + const approvedBuildIds = new Set( + (approvedPlan.deleteBuilds ?? []) + .map((b) => (typeof b === 'string' ? b : b?.buildId)) + .filter(Boolean), + ); + const approvedAssetKeys = new Set(approvedPlan.deleteAssets ?? []); + const freshBuilds = plan.deleteBuilds; + const freshAssets = plan.deleteAssets; + plan.deleteBuilds = freshBuilds.filter((b) => approvedBuildIds.has(b.buildId)); + plan.deleteAssets = freshAssets.filter((k) => approvedAssetKeys.has(k)); + const deferredBuilds = freshBuilds.length - plan.deleteBuilds.length; + const deferredAssets = freshAssets.length - plan.deleteAssets.length; + const staleBuilds = approvedBuildIds.size - plan.deleteBuilds.length; + const staleAssets = approvedAssetKeys.size - plan.deleteAssets.length; + if (deferredBuilds || deferredAssets) { + logger.log( + `[cleanup] approval filter: ${deferredBuilds} build(s) and ${deferredAssets} ` + + 'asset(s) became deletable after the plan was approved — deferred to a future run.', + ); + } + if (staleBuilds || staleAssets) { + logger.warn( + `[cleanup] WARNING: ${staleBuilds} approved build(s) and ${staleAssets} approved ` + + 'asset(s) are no longer deletable — they stay protected.', + ); + } + } + + logger.log( + `[cleanup] ${builds.length} build(s), ${assets.length} shared asset(s), ` + + `channels: ${normalizedPointers(channels)}`, + ); + for (const key of unknown) { + logger.warn(`[cleanup] WARNING: unrecognized key left untouched: ${key.key}`); + } + for (const warning of plan.warnings) { + logger.warn(`[cleanup] WARNING: ${warning}`); + } + for (const build of plan.keepBuilds) { + logger.log(` keep ${build.buildId} (${build.reasons.join(', ')})`); + } + for (const build of plan.deleteBuilds) { + logger.log(` delete ${build.buildId} — ${build.reason} (${build.keys.length} object(s))`); + } + logger.log( + `[cleanup] assets: ${plan.referencedAssets} referenced, ` + + `${plan.gracedAssets} in grace period, ${plan.deleteAssets.length} to delete`, + ); + + if (!apply) { + logger.log('[cleanup] dry run — nothing deleted. Re-run with --apply to delete.'); + return { ...plan, unknown: unknown.map((k) => k.key), applied: false, deletedObjects: 0 }; + } + + // The pointer set is the ground truth for what must survive. If a promote + // or rollback landed while we were planning, the plan is stale — abort. + const pointersNow = await ops.readChannelPointers(ctx); + if (normalizedPointers(pointersNow) !== normalizedPointers(channels)) { + throw new Error( + '[cleanup] channel pointers changed while planning ' + + `(${normalizedPointers(channels)} → ${normalizedPointers(pointersNow)}) — re-run.`, + ); + } + + let deletedObjects = 0; + for (const build of plan.deleteBuilds) { + const manifestKey = `${build.buildId}/manifest.json`; + // Manifest first: without it the prefix can no longer be promoted, so + // an interrupted deletion never leaves a promotable half-build behind. + const ordered = [ + ...build.keys.filter((k) => k === manifestKey), + ...build.keys.filter((k) => k !== manifestKey), + ]; + for (const key of ordered) { + await ops.deleteObject(ctx, key); + deletedObjects += 1; + logger.log(` ✕ ${key}`); + } + } + await runWithConcurrency(plan.deleteAssets, deleteConcurrency, async (key) => { + await ops.deleteObject(ctx, key); + deletedObjects += 1; + logger.log(` ✕ ${key}`); + }); + + logger.log( + `[cleanup] deleted ${plan.deleteBuilds.length} build(s) and ` + + `${plan.deleteAssets.length} asset(s) (${deletedObjects} object(s) total).`, + ); + return { ...plan, unknown: unknown.map((k) => k.key), applied: true, deletedObjects }; +} + +export function parseCleanupArgs(argv) { + const options = { apply: false }; + const requireNumber = (raw, flag) => { + const value = Number(raw); + if (!Number.isFinite(value) || value < 0) { + throw new Error(`[cleanup] ${flag} expects a non-negative number, got ${JSON.stringify(raw)}`); + } + return value; + }; + const requirePath = (raw, flag) => { + if (typeof raw !== 'string' || raw.length === 0) { + throw new Error(`[cleanup] ${flag} expects a file path`); + } + return raw; + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--apply') options.apply = true; + else if (arg === '--keep-recent') { + options.minRecentPerChannel = requireNumber(argv[(i += 1)], arg); + } else if (arg === '--asset-grace-days') { + options.assetGraceMs = requireNumber(argv[(i += 1)], arg) * DAY_MS; + } else if (arg === '--build-grace-days') { + options.orphanGraceMs = requireNumber(argv[(i += 1)], arg) * DAY_MS; + } else if (arg === '--plan-out') { + options.planOut = requirePath(argv[(i += 1)], arg); + } else if (arg === '--plan-in') { + options.planIn = requirePath(argv[(i += 1)], arg); + } else { + throw new Error(`[cleanup] unknown argument: ${arg}`); + } + } + return options; +} + +/** + * CLI wrapper for cleanup-azure.mjs. Adds the approval round-trip: + * `--plan-out ` captures the dry-run plan for a human to review, and + * `--apply --plan-in ` deletes only what that reviewed plan approved. + */ +export async function runCleanupCli({ argv, ctx, ops, logger = console, now }) { + const { planOut, planIn, ...options } = parseCleanupArgs(argv); + if (planOut && options.apply) { + throw new Error('[cleanup] --plan-out captures a dry-run plan; drop --apply.'); + } + if (planIn && !options.apply) { + throw new Error('[cleanup] --plan-in only applies an approved plan; add --apply.'); + } + const approvedPlan = planIn ? JSON.parse(readFileSync(planIn, 'utf8')) : undefined; + const result = await runCleanup({ + ctx, + ops, + logger, + approvedPlan, + ...(now !== undefined ? { now } : {}), + ...options, + }); + if (planOut) { + writeFileSync( + planOut, + JSON.stringify({ generatedAt: new Date(now ?? Date.now()).toISOString(), ...result }, null, 2), + ); + logger.log(`[cleanup] plan written to ${planOut}`); + } + return result; +} diff --git a/apps/widget-cdn/scripts/cleanup.test.mjs b/apps/widget-cdn/scripts/cleanup.test.mjs new file mode 100644 index 0000000000..47b0dfc462 --- /dev/null +++ b/apps/widget-cdn/scripts/cleanup.test.mjs @@ -0,0 +1,434 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { listObjects } from "./azure-lib.mjs"; +import { readAzureChannelPointers } from "./cleanup-azure.mjs"; +import { + parseCleanupArgs, + partitionObjects, + planCleanup, + runCleanup, + runCleanupCli, +} from "./cleanup-lib.mjs"; + +const silentLogger = { log() {}, warn() {} }; + +const DAY_MS = 24 * 60 * 60 * 1000; +const NOW = Date.parse("2026-08-01T00:00:00Z"); + +function iso(offsetDays) { + return new Date(NOW + offsetDays * DAY_MS).toISOString(); +} + +function build(buildId, { manifest, ageDays = 10, extraKeys = [] } = {}) { + const lastModified = new Date(NOW - ageDays * DAY_MS); + const keys = [ + ...(manifest !== null + ? [{ key: `${buildId}/manifest.json`, lastModified }] + : []), + { key: `${buildId}/remoteEntry.js`, lastModified }, + ...extraKeys.map((key) => ({ key, lastModified })), + ]; + return { buildId, keys, manifest: manifest ?? null }; +} + +function manifestFor(buildId, { channel = "v1", issuedDays = -10, expiresDays = 20, chunks = {} } = {}) { + return { + buildId, + channel, + issuedAt: iso(issuedDays), + expiresAt: iso(expiresDays), + chunks, + }; +} + +test("partitionObjects separates builds, assets, channels, and unknowns", () => { + const { builds, assets, channelObjects, unknown } = partitionObjects([ + { key: "1.7.0-abc/manifest.json" }, + { key: "1.7.0-abc/remoteEntry.js" }, + { key: "assets/837.0123456789abcdef.js" }, + { key: "assets/README.txt" }, + { key: "v1/manifest.json" }, + { key: "stray-file" }, + ]); + assert.deepEqual([...builds.keys()], ["1.7.0-abc"]); + assert.equal(builds.get("1.7.0-abc").length, 2); + assert.deepEqual(assets.map((a) => a.name), ["837.0123456789abcdef.js"]); + assert.deepEqual(channelObjects.map((o) => o.key), ["v1/manifest.json"]); + assert.deepEqual(unknown.map((o) => o.key), ["assets/README.txt", "stray-file"]); +}); + +test("planCleanup keeps promoted and unexpired builds, deletes expired ones", () => { + const chunk = "837.0123456789abcdef.js"; + const promoted = build("1.7.0-promoted", { + manifest: manifestFor("1.7.0-promoted", { expiresDays: -5, chunks: { [chunk]: "sha384-x" } }), + ageDays: 60, + }); + const fresh = build("1.7.0-fresh", { + manifest: manifestFor("1.7.0-fresh", { issuedDays: -1, expiresDays: 29 }), + }); + const stale = [1, 2, 3, 4].map((n) => + build(`1.6.${n}-stale`, { + manifest: manifestFor(`1.6.${n}-stale`, { issuedDays: -100 - n, expiresDays: -70 - n }), + ageDays: 100 + n, + }), + ); + + const plan = planCleanup({ + now: NOW, + channels: { v1: "1.7.0-promoted" }, + builds: [promoted, fresh, ...stale], + assets: [], + minRecentPerChannel: 2, + }); + + const kept = Object.fromEntries(plan.keepBuilds.map((b) => [b.buildId, b.reasons])); + // Promoted survives even though expired — and that expiry is loudly warned. + assert.deepEqual(kept["1.7.0-promoted"], ["recent", "promoted:v1"]); + assert.ok(kept["1.7.0-fresh"].includes("unexpired")); + // minRecentPerChannel=2 → promoted + fresh are the 2 most recent; all four + // stale expired builds go. + assert.deepEqual( + plan.deleteBuilds.map((b) => b.buildId).sort(), + ["1.6.1-stale", "1.6.2-stale", "1.6.3-stale", "1.6.4-stale"], + ); + assert.ok(plan.warnings.some((w) => w.includes("EXPIRED"))); +}); + +test("planCleanup keeps the N most recent builds per channel even when all expired", () => { + const builds = [1, 2, 3].map((n) => + build(`1.5.${n}-old`, { + manifest: manifestFor(`1.5.${n}-old`, { issuedDays: -100 + n, expiresDays: -70 + n }), + }), + ); + const plan = planCleanup({ + now: NOW, + channels: {}, + builds, + assets: [], + minRecentPerChannel: 2, + }); + assert.deepEqual( + plan.keepBuilds.map((b) => b.buildId).sort(), + ["1.5.2-old", "1.5.3-old"], + ); + assert.deepEqual(plan.deleteBuilds.map((b) => b.buildId), ["1.5.1-old"]); +}); + +test("planCleanup grace-protects manifest-less uploads, deletes old orphans", () => { + const inFlight = build("1.8.0-inflight", { manifest: null, ageDays: 0.5 }); + const abandoned = build("1.8.0-abandoned", { manifest: null, ageDays: 30 }); + const plan = planCleanup({ + now: NOW, + channels: {}, + builds: [inFlight, abandoned], + assets: [], + minRecentPerChannel: 0, + }); + assert.deepEqual(plan.keepBuilds, [ + { buildId: "1.8.0-inflight", reasons: ["upload-grace"] }, + ]); + assert.deepEqual(plan.deleteBuilds.map((b) => b.buildId), ["1.8.0-abandoned"]); +}); + +test("planCleanup mark-and-sweeps shared assets against retained manifests", () => { + const referencedChunk = "aaa.0123456789abcdef.js"; + const kept = build("1.7.0-live", { + manifest: manifestFor("1.7.0-live", { + chunks: { [referencedChunk]: "sha384-a", "remoteEntry.js": "sha384-r" }, + }), + }); + const plan = planCleanup({ + now: NOW, + channels: { v1: "1.7.0-live" }, + builds: [kept], + assets: [ + { key: `assets/${referencedChunk}`, name: referencedChunk, lastModified: new Date(NOW - 90 * DAY_MS) }, + { key: "assets/bbb.fedcba9876543210.js", name: "bbb.fedcba9876543210.js", lastModified: new Date(NOW - 90 * DAY_MS) }, + { key: "assets/ccc.1111111111111111.js", name: "ccc.1111111111111111.js", lastModified: new Date(NOW - 1 * 60 * 60 * 1000) }, + ], + }); + assert.deepEqual(plan.deleteAssets, ["assets/bbb.fedcba9876543210.js"]); + assert.equal(plan.referencedAssets, 1); + assert.equal(plan.gracedAssets, 1); +}); + +test("planCleanup keeps and warns about anomalies", () => { + const mismatched = build("1.7.0-prefix", { + manifest: { buildId: "1.7.0-OTHER", channel: "v1", expiresAt: iso(-50), issuedAt: iso(-80), chunks: {} }, + }); + const plan = planCleanup({ + now: NOW, + channels: { v1: "1.9.9-missing" }, + builds: [mismatched], + assets: [], + minRecentPerChannel: 0, + }); + assert.deepEqual(plan.deleteBuilds, []); + assert.ok(plan.warnings.some((w) => w.includes("no published build prefix"))); + assert.ok(plan.warnings.some((w) => w.includes("manifest claiming to be"))); +}); + +function fakeStorage() { + const objects = new Map([ + [ + "1.7.0-live/manifest.json", + JSON.stringify(manifestFor("1.7.0-live", { chunks: { "aaa.0123456789abcdef.js": "sha384-a" } })), + ], + ["1.7.0-live/remoteEntry.js", "remote"], + [ + "1.6.0-dead/manifest.json", + JSON.stringify(manifestFor("1.6.0-dead", { issuedDays: -100, expiresDays: -70 })), + ], + ["1.6.0-dead/remoteEntry.js", "remote"], + ["assets/aaa.0123456789abcdef.js", "chunk"], + ["assets/bbb.fedcba9876543210.js", "chunk"], + ]); + const deletions = []; + return { + objects, + deletions, + ops: { + listObjects: async () => + [...objects.keys()].map((key) => ({ key, lastModified: new Date(NOW - 90 * DAY_MS) })), + readJson: async (_ctx, key) => + objects.has(key) ? JSON.parse(objects.get(key)) : undefined, + readChannelPointers: async () => ({ v1: "1.7.0-live" }), + deleteObject: async (_ctx, key) => { + deletions.push(key); + objects.delete(key); + }, + }, + }; +} + +test("runCleanup dry run reports the plan and deletes nothing", async () => { + const storage = fakeStorage(); + const result = await runCleanup({ + ctx: {}, + ops: storage.ops, + now: NOW, + logger: silentLogger, + minRecentPerChannel: 1, + }); + assert.equal(result.applied, false); + assert.deepEqual(result.deleteBuilds.map((b) => b.buildId), ["1.6.0-dead"]); + assert.deepEqual(result.deleteAssets, ["assets/bbb.fedcba9876543210.js"]); + assert.deepEqual(storage.deletions, []); +}); + +test("runCleanup --apply deletes the build manifest before its payload", async () => { + const storage = fakeStorage(); + const result = await runCleanup({ + ctx: {}, + ops: storage.ops, + apply: true, + now: NOW, + logger: silentLogger, + minRecentPerChannel: 1, + }); + assert.equal(result.applied, true); + assert.deepEqual(storage.deletions, [ + "1.6.0-dead/manifest.json", + "1.6.0-dead/remoteEntry.js", + "assets/bbb.fedcba9876543210.js", + ]); + assert.ok(storage.objects.has("1.7.0-live/manifest.json")); + assert.ok(storage.objects.has("assets/aaa.0123456789abcdef.js")); +}); + +test("runCleanup aborts apply when a channel pointer changes mid-plan", async () => { + const storage = fakeStorage(); + let reads = 0; + storage.ops.readChannelPointers = async () => + (reads += 1) === 1 ? { v1: "1.7.0-live" } : { v1: "1.6.0-dead" }; + await assert.rejects( + runCleanup({ + ctx: {}, + ops: storage.ops, + apply: true, + now: NOW, + logger: silentLogger, + minRecentPerChannel: 1, + }), + /channel pointers changed/, + ); + assert.deepEqual(storage.deletions, []); +}); + +test("Azure listObjects paginates and decodes the XML listing", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + const pages = [ + ` + + 1.7.0-abc/manifest.jsonFri, 01 Aug 2026 00:00:00 GMT + assets/a&b.0123456789abcdef.jsFri, 01 Aug 2026 00:00:00 GMT + + page-2 + `, + ` + + v1/manifest.jsonFri, 01 Aug 2026 00:00:00 GMT + + + `, + ]; + const requestedUrls = []; + globalThis.fetch = async (url) => { + requestedUrls.push(String(url)); + return new Response(pages.shift(), { status: 200 }); + }; + + const results = await listObjects({ + endpoint: "https://test.blob.core.windows.net", + containerName: "widget-cdn", + accessToken: "token", + }); + + assert.deepEqual( + results.map((r) => r.key), + ["1.7.0-abc/manifest.json", "assets/a&b.0123456789abcdef.js", "v1/manifest.json"], + ); + assert.equal(results[0].lastModified.toISOString(), "2026-08-01T00:00:00.000Z"); + assert.equal(requestedUrls.length, 2); + assert.match(requestedUrls[1], /marker=page-2/); +}); + +test("Azure channel pointers come from vN/manifest.json blobs", async () => { + const pointers = await readAzureChannelPointers( + {}, + { + list: async () => [ + { key: "v1/manifest.json" }, + { key: "v2/manifest.json" }, + { key: "v1-not-a-channel/manifest.json" }, + ], + readJson: async (_ctx, key) => + key === "v1/manifest.json" + ? { buildId: "1.7.0-abc" } + : { buildId: "!!invalid!!" }, + }, + ); + assert.deepEqual(pointers, { v1: "1.7.0-abc" }); +}); + +test("runCleanup --apply with an approved plan deletes only its intersection with fresh state", async () => { + const storage = fakeStorage(); + // The approved plan covers the dead build but NOT the orphaned asset — the + // asset became deletable after approval and must be deferred. It also + // approves a build that no longer exists — skipped, not an error. + const result = await runCleanup({ + ctx: {}, + ops: storage.ops, + apply: true, + approvedPlan: { + deleteBuilds: [{ buildId: "1.6.0-dead" }, { buildId: "1.0.0-gone" }], + deleteAssets: [], + }, + now: NOW, + logger: silentLogger, + minRecentPerChannel: 1, + }); + assert.equal(result.applied, true); + assert.deepEqual(storage.deletions, [ + "1.6.0-dead/manifest.json", + "1.6.0-dead/remoteEntry.js", + ]); + assert.ok(storage.objects.has("assets/bbb.fedcba9876543210.js")); +}); + +test("runCleanupCli round-trips a plan file from --plan-out to --apply --plan-in", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "layerswap-cleanup-plan-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const planPath = join(dir, "plan.json"); + + const storage = fakeStorage(); + const planned = await runCleanupCli({ + argv: ["--plan-out", planPath, "--keep-recent", "1"], + ctx: {}, + ops: storage.ops, + logger: silentLogger, + now: NOW, + }); + assert.equal(planned.applied, false); + assert.deepEqual(storage.deletions, []); + const written = JSON.parse(readFileSync(planPath, "utf8")); + assert.deepEqual(written.deleteBuilds.map((b) => b.buildId), ["1.6.0-dead"]); + assert.deepEqual(written.deleteAssets, ["assets/bbb.fedcba9876543210.js"]); + + const applied = await runCleanupCli({ + argv: ["--apply", "--plan-in", planPath, "--keep-recent", "1"], + ctx: {}, + ops: storage.ops, + logger: silentLogger, + now: NOW, + }); + assert.equal(applied.applied, true); + assert.deepEqual(storage.deletions, [ + "1.6.0-dead/manifest.json", + "1.6.0-dead/remoteEntry.js", + "assets/bbb.fedcba9876543210.js", + ]); +}); + +test("runCleanupCli rejects contradictory plan flags", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "layerswap-cleanup-flags-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const planPath = join(dir, "plan.json"); + writeFileSync(planPath, "{}"); + const storage = fakeStorage(); + await assert.rejects( + runCleanupCli({ + argv: ["--apply", "--plan-out", planPath], + ctx: {}, + ops: storage.ops, + logger: silentLogger, + now: NOW, + }), + /--plan-out captures a dry-run plan/, + ); + await assert.rejects( + runCleanupCli({ + argv: ["--plan-in", planPath], + ctx: {}, + ops: storage.ops, + logger: silentLogger, + now: NOW, + }), + /--plan-in only applies an approved plan/, + ); + assert.deepEqual(storage.deletions, []); +}); + +test("parseCleanupArgs parses flags and rejects unknown ones", () => { + assert.deepEqual(parseCleanupArgs([]), { apply: false }); + const parsed = parseCleanupArgs([ + "--apply", + "--keep-recent", + "5", + "--asset-grace-days", + "3", + "--build-grace-days", + "1", + ]); + assert.equal(parsed.apply, true); + assert.equal(parsed.minRecentPerChannel, 5); + assert.equal(parsed.assetGraceMs, 3 * 24 * 60 * 60 * 1000); + assert.equal(parsed.orphanGraceMs, 1 * 24 * 60 * 60 * 1000); + assert.deepEqual(parseCleanupArgs(["--plan-out", "p.json"]), { + apply: false, + planOut: "p.json", + }); + assert.deepEqual(parseCleanupArgs(["--apply", "--plan-in", "p.json"]), { + apply: true, + planIn: "p.json", + }); + assert.throws(() => parseCleanupArgs(["--nope"]), /unknown argument/); + assert.throws(() => parseCleanupArgs(["--keep-recent", "x"]), /non-negative number/); + assert.throws(() => parseCleanupArgs(["--plan-out"]), /expects a file path/); +}); diff --git a/apps/widget-cdn/scripts/deploy-azure.mjs b/apps/widget-cdn/scripts/deploy-azure.mjs index d7d25dff14..6ed96d23a9 100644 --- a/apps/widget-cdn/scripts/deploy-azure.mjs +++ b/apps/widget-cdn/scripts/deploy-azure.mjs @@ -4,7 +4,7 @@ // Immutable build controls live at // and content-hashed chunks live // in /assets/. Promotion atomically copies the same signed manifest to // //manifest.json; its build-addressed remoteEntry keeps all code on -// immutable URLs without needing a Cloudflare Worker or Azure Function. +// immutable URLs without needing any edge redirect service. import { existsSync, readFileSync, readdirSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; diff --git a/apps/widget-cdn/scripts/deploy-r2.mjs b/apps/widget-cdn/scripts/deploy-r2.mjs deleted file mode 100644 index d3fb70872b..0000000000 --- a/apps/widget-cdn/scripts/deploy-r2.mjs +++ /dev/null @@ -1,211 +0,0 @@ -#!/usr/bin/env node -// Upload signed build control files from `dist//` to the immutable -// `/` prefix and content-hashed chunks to the shared `/assets/` -// namespace. Unless LAYERSWAP_PROMOTE=false, the rolling channel is then -// updated in `channels.json`. - -import { readFileSync, existsSync, readdirSync } from 'node:fs'; -import { dirname, join, resolve, relative } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { - makeClient, - objectExists, - putObject, - readChannels, - writeChannels, - contentTypeFor, -} from './r2-lib.mjs'; -import { resolveBuildIdentity } from './build-id.mjs'; -import { WIDGET_PROTOCOL_MAJOR } from '@layerswap/widget-types'; -import { - ASSET_BASE, - ASSET_DIRECTORY, - deploymentKey, - isSharedAsset, -} from './cdn-layout.mjs'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, '..'); - -export function validateManifestIdentity(manifest, identity) { - const mismatches = [ - ['buildId', manifest.buildId, identity.buildId], - ['protocolMajor', manifest.protocolMajor, WIDGET_PROTOCOL_MAJOR], - ['version', manifest.version, identity.version], - ['channel', manifest.channel, identity.channel], - ['gitSha', manifest.gitSha, identity.gitSha], - ['assetBase', manifest.assetBase, ASSET_BASE], - ].filter(([, actual, expected]) => actual !== expected); - - if (mismatches.length > 0) { - const details = mismatches - .map( - ([field, actual, expected]) => ( - `${field}: ${JSON.stringify(actual)} !== ${JSON.stringify(expected)}` - ), - ) - .join(', '); - throw new Error(`[deploy-r2] manifest identity does not match this build: ${details}`); - } -} - -function listFiles(dir) { - const out = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) out.push(...listFiles(full)); - else if (entry.isFile()) out.push(full); - } - return out; -} - -async function runWithConcurrency(items, limit, task) { - if (items.length === 0) return; - const workerCount = Math.min(Math.max(1, Math.floor(limit)), items.length); - let cursor = 0; - await Promise.all(Array.from({ length: workerCount }, async () => { - while (cursor < items.length) { - const item = items[cursor]; - cursor += 1; - await task(item); - } - })); -} - -export async function deployBuild(options = {}) { - const root = options.root ?? ROOT; - const identity = options.identity ?? resolveBuildIdentity(root); - const env = options.env ?? process.env; - const logger = options.logger ?? console; - const createClient = options.createClient ?? makeClient; - const exists = options.objectExists ?? objectExists; - const upload = options.putObject ?? putObject; - const readChannelMap = options.readChannels ?? readChannels; - const writeChannelMap = options.writeChannels ?? writeChannels; - const uploadConcurrency = options.uploadConcurrency ?? 8; - const dist = join(root, 'dist', identity.buildId); - const assetDist = join(root, 'dist', ASSET_DIRECTORY); - const manifestPath = join(dist, 'manifest.json'); - - if (!existsSync(manifestPath)) { - throw new Error(`[deploy-r2] missing ${manifestPath} — run \`pnpm build\` first.`); - } - - let manifest; - try { - manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); - } catch (err) { - throw new Error( - `[deploy-r2] failed to read manifest: ${err instanceof Error ? err.message : String(err)}`, - ); - } - if (!manifest.signature) { - throw new Error( - '[deploy-r2] refusing to deploy an UNSIGNED manifest. ' - + 'Build with LAYERSWAP_PRIVATE_KEY_PEM set.', - ); - } - - // This must run before credentials are read or an R2 client is created: - // stale/copied artifacts must never upload under the wrong immutable key. - validateManifestIdentity(manifest, identity); - if (!existsSync(assetDist)) { - throw new Error(`[deploy-r2] missing shared asset output ${assetDist} — run \`pnpm build\` first.`); - } - - const ctx = createClient(); - if (await exists(ctx, `${identity.buildId}/manifest.json`)) { - if (env.ALLOW_OVERWRITE !== '1') { - throw new Error( - `[deploy-r2] build ${identity.buildId} is already published (immutable). ` - + 'Deploy from a new commit, or set ALLOW_OVERWRITE=1 to force.', - ); - } - } - - // Prepare and validate every key before uploading anything. The manifest - // is held aside and published only after every payload worker succeeds, so - // an interrupted upload can never make an incomplete build rollback-ready. - const files = [ - ...listFiles(dist).map((file) => ({ file, root: dist, shared: false })), - ...(existsSync(assetDist) - ? listFiles(assetDist).map((file) => ({ file, root: assetDist, shared: true })) - : []), - ].sort((a, b) => a.file.localeCompare(b.file)); - - const publishableFiles = files.map(({ file, root: fileRoot, shared }) => { - const rel = relative(fileRoot, file).split(/[\\/]/).join('/'); - if (shared && !isSharedAsset(rel)) { - throw new Error(`[deploy-r2] refusing non-content-hashed file in ${ASSET_DIRECTORY}/: ${rel}`); - } - return { - file, - rel, - shared, - key: shared ? deploymentKey(identity.buildId, rel) : `${identity.buildId}/${rel}`, - }; - }); - const manifestFile = publishableFiles.find(({ file }) => file === manifestPath); - const payloadFiles = publishableFiles.filter(({ file }) => file !== manifestPath); - if (!manifestFile) { - throw new Error(`[deploy-r2] missing ${manifestPath} from publishable files.`); - } - - let uploaded = 0; - let reused = 0; - logger.log(`[deploy-r2] publishing ${files.length} file(s) to ${ctx.bucket} …`); - - const publishFile = async ({ file, rel, shared, key }) => { - // A content hash is the immutable asset key. Reuse an existing object - // instead of storing or transferring the same chunk for every build. - if (shared && env.ALLOW_OVERWRITE !== '1' && await exists(ctx, key)) { - reused += 1; - logger.log(` = ${key} (already published)`); - return; - } - - const body = readFileSync(file); - await upload(ctx, key, body, { - contentType: contentTypeFor(rel), - cacheControl: 'public, max-age=31536000, immutable', - }); - uploaded += 1; - logger.log(` ↑ ${key} (${body.length} bytes)`); - }; - - await runWithConcurrency(payloadFiles, uploadConcurrency, publishFile); - await publishFile(manifestFile); - - logger.log( - `[deploy-r2] published build ${identity.buildId} (${uploaded} uploaded, ${reused} reused).`, - ); - - if (env.LAYERSWAP_PROMOTE === 'false') { - logger.log( - `[deploy-r2] LAYERSWAP_PROMOTE=false — channel ${identity.channel} NOT changed. ` - + 'Promote later with:', - ); - logger.log(` node scripts/rollback-r2.mjs ${identity.channel} ${identity.buildId}`); - return { uploaded, reused, promoted: false }; - } - - const channels = await readChannelMap(ctx); - const previous = channels[identity.channel]; - channels[identity.channel] = identity.buildId; - await writeChannelMap(ctx, channels); - logger.log( - `[deploy-r2] channel ${identity.channel}: ${previous ?? '(none)'} → ${identity.buildId}`, - ); - logger.log(`[deploy-r2] live at /${identity.channel}/manifest.json`); - return { uploaded, reused, promoted: true }; -} - -const entryUrl = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined; -if (entryUrl === import.meta.url) { - try { - await deployBuild(); - } catch (err) { - console.error(err instanceof Error ? err.message : err); - process.exitCode = 1; - } -} diff --git a/apps/widget-cdn/scripts/r2-lib.mjs b/apps/widget-cdn/scripts/r2-lib.mjs deleted file mode 100644 index 55f8e31a2e..0000000000 --- a/apps/widget-cdn/scripts/r2-lib.mjs +++ /dev/null @@ -1,114 +0,0 @@ -// Shared R2 helpers for the deploy + rollback scripts. -// -// R2 is S3-compatible, so we drive it with the AWS S3 SDK pointed at the -// account's R2 endpoint. Credentials come from env (an R2 API token with -// Object Read & Write on the target bucket): -// -// R2_ACCOUNT_ID Cloudflare account id -// R2_ACCESS_KEY_ID R2 token access key id -// R2_SECRET_ACCESS_KEY R2 token secret -// R2_BUCKET bucket name (default: layerswap-widget-cdn) - -import { - S3Client, - PutObjectCommand, - GetObjectCommand, - HeadObjectCommand, -} from '@aws-sdk/client-s3'; - -export const CHANNELS_KEY = 'channels.json'; - -const CONTENT_TYPES = { - js: 'text/javascript; charset=utf-8', - mjs: 'text/javascript; charset=utf-8', - css: 'text/css; charset=utf-8', - json: 'application/json; charset=utf-8', - map: 'application/json; charset=utf-8', -}; - -export function contentTypeFor(name) { - const ext = name.split('.').pop()?.toLowerCase() ?? ''; - return CONTENT_TYPES[ext] ?? 'application/octet-stream'; -} - -function requireEnv(name) { - const v = process.env[name]; - if (!v) { - console.error(`[r2] missing required env var ${name}`); - process.exit(1); - } - return v; -} - -export function makeClient() { - const accountId = requireEnv('R2_ACCOUNT_ID'); - return { - bucket: process.env.R2_BUCKET || 'layerswap-widget-cdn', - client: new S3Client({ - region: 'auto', - endpoint: `https://${accountId}.r2.cloudflarestorage.com`, - credentials: { - accessKeyId: requireEnv('R2_ACCESS_KEY_ID'), - secretAccessKey: requireEnv('R2_SECRET_ACCESS_KEY'), - }, - }), - }; -} - -export async function objectExists({ client, bucket }, key) { - try { - await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key })); - return true; - } catch (err) { - if (err?.$metadata?.httpStatusCode === 404 || err?.name === 'NotFound') return false; - throw err; - } -} - -export async function putObject({ client, bucket }, key, body, { contentType, cacheControl } = {}) { - await client.send( - new PutObjectCommand({ - Bucket: bucket, - Key: key, - Body: body, - ContentType: contentType ?? contentTypeFor(key), - CacheControl: cacheControl, - }), - ); -} - -export async function readJsonObject(ctx, key) { - try { - const res = await ctx.client.send( - new GetObjectCommand({ Bucket: ctx.bucket, Key: key }), - ); - const text = await res.Body.transformToString(); - const value = JSON.parse(text); - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error(`[r2] ${key} does not contain a JSON object`); - } - return value; - } catch (err) { - if ( - err?.$metadata?.httpStatusCode === 404 - || err?.name === 'NoSuchKey' - || err?.name === 'NotFound' - ) { - return undefined; - } - throw err; - } -} - -export async function readChannels(ctx) { - return (await readJsonObject(ctx, CHANNELS_KEY)) ?? {}; -} - -export async function writeChannels(ctx, channels) { - await putObject(ctx, CHANNELS_KEY, JSON.stringify(channels, null, 2), { - contentType: 'application/json; charset=utf-8', - // The pointer must never be cached hard — the Worker reads the freshest - // copy so channel flips (roll-forward / rollback) take effect promptly. - cacheControl: 'no-store', - }); -} diff --git a/apps/widget-cdn/scripts/release-scripts.test.mjs b/apps/widget-cdn/scripts/release-scripts.test.mjs deleted file mode 100644 index 9dbbbb1474..0000000000 --- a/apps/widget-cdn/scripts/release-scripts.test.mjs +++ /dev/null @@ -1,241 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import test from 'node:test'; -import { deployBuild } from './deploy-r2.mjs'; -import { ASSET_BASE, deploymentKey } from './cdn-layout.mjs'; -import { rollbackChannel } from './rollback-r2.mjs'; - -const silentLogger = { log() {}, warn() {} }; - -function deployFixture(t, identity, manifestOverrides = {}) { - const root = mkdtempSync(join(tmpdir(), 'layerswap-deploy-r2-')); - const dist = join(root, 'dist', identity.buildId); - mkdirSync(dist, { recursive: true }); - writeFileSync(join(dist, 'manifest.json'), JSON.stringify({ - ...identity, - protocolMajor: 1, - remoteEntry: './remoteEntry.js', - assetBase: ASSET_BASE, - chunks: {}, - signature: 'signed', - ...manifestOverrides, - })); - t.after(() => rmSync(root, { recursive: true, force: true })); - return root; -} - -test('deploy rejects a mismatched manifest before creating an R2 client', async (t) => { - const identity = { - buildId: '1.7.0-0123456789ab', - version: '1.7.0', - channel: 'v1', - gitSha: '0123456789abcdef', - }; - const root = deployFixture(t, identity, { buildId: '1.7.0-fedcba987654' }); - let clientsCreated = 0; - - await assert.rejects( - deployBuild({ - root, - identity, - logger: silentLogger, - createClient() { - clientsCreated += 1; - return {}; - }, - }), - /manifest identity does not match this build/, - ); - assert.equal(clientsCreated, 0); -}); - -test('content-hashed chunks use the shared asset namespace', () => { - assert.equal( - deploymentKey('1.7.0-0123456789ab', '837.0123456789abcdef.js'), - 'assets/837.0123456789abcdef.js', - ); - assert.equal( - deploymentKey('1.7.0-0123456789ab', 'remoteEntry.js'), - '1.7.0-0123456789ab/remoteEntry.js', - ); -}); - -test('deploy reuses an existing shared asset and publishes build controls', async (t) => { - const identity = { - buildId: '1.7.0-0123456789ab', - version: '1.7.0', - channel: 'v1', - gitSha: '0123456789abcdef', - }; - const root = deployFixture(t, identity); - const buildDir = join(root, 'dist', identity.buildId); - const assetDir = join(root, 'dist', 'assets'); - const assetName = '837.0123456789abcdef.js'; - mkdirSync(assetDir, { recursive: true }); - writeFileSync(join(buildDir, 'remoteEntry.js'), 'remote'); - writeFileSync(join(assetDir, assetName), 'chunk'); - - const uploadedKeys = []; - const result = await deployBuild({ - root, - identity, - env: { LAYERSWAP_PROMOTE: 'false' }, - logger: silentLogger, - createClient: () => ({ bucket: 'test' }), - objectExists: async (_ctx, key) => key === `assets/${assetName}`, - putObject: async (_ctx, key) => uploadedKeys.push(key), - }); - - assert.deepEqual(result, { uploaded: 2, reused: 1, promoted: false }); - assert.deepEqual(uploadedKeys, [ - `${identity.buildId}/remoteEntry.js`, - `${identity.buildId}/manifest.json`, - ]); -}); - -test('deploy uploads payloads concurrently and publishes the manifest last', async (t) => { - const identity = { - buildId: '1.7.0-0123456789ab', - version: '1.7.0', - channel: 'v1', - gitSha: '0123456789abcdef', - }; - const root = deployFixture(t, identity); - const buildDir = join(root, 'dist', identity.buildId); - const assetDir = join(root, 'dist', 'assets'); - mkdirSync(assetDir, { recursive: true }); - writeFileSync(join(buildDir, 'remoteEntry.js'), 'remote'); - writeFileSync(join(assetDir, 'one.0123456789abcdef.js'), 'one'); - writeFileSync(join(assetDir, 'two.fedcba9876543210.js'), 'two'); - - let activePayloadUploads = 0; - let maxActivePayloadUploads = 0; - const completedKeys = []; - await deployBuild({ - root, - identity, - env: { LAYERSWAP_PROMOTE: 'false' }, - logger: silentLogger, - uploadConcurrency: 2, - createClient: () => ({ bucket: 'test' }), - objectExists: async () => false, - putObject: async (_ctx, key) => { - if (key.endsWith('/manifest.json')) { - assert.equal(activePayloadUploads, 0, 'manifest started before payloads finished'); - } else { - activePayloadUploads += 1; - maxActivePayloadUploads = Math.max(maxActivePayloadUploads, activePayloadUploads); - await new Promise(resolve => setTimeout(resolve, 10)); - activePayloadUploads -= 1; - } - completedKeys.push(key); - }, - }); - - assert.equal(maxActivePayloadUploads, 2); - assert.equal(completedKeys.at(-1), `${identity.buildId}/manifest.json`); -}); - -test('rollback accepts a matching signed legacy channel manifest', async () => { - const writes = []; - const result = await rollbackChannel({ - channel: 'v1', - buildId: '1.7.0-0123456789ab', - ctx: {}, - logger: silentLogger, - readManifest: async () => ({ - buildId: '1.7.0-0123456789ab', - channel: 'v1', - }), - readChannelMap: async () => ({ v1: '1.6.0-aaaaaaaaaaaa' }), - writeChannelMap: async (_ctx, channels) => writes.push({ ...channels }), - }); - - assert.equal(result.changed, true); - assert.deepEqual(writes, [{ v1: '1.7.0-0123456789ab' }]); -}); - -test('rollback rejects a missing target without changing the channel', async () => { - let writes = 0; - await assert.rejects( - rollbackChannel({ - channel: 'v1', - buildId: '1.7.0-0123456789ab', - ctx: {}, - logger: silentLogger, - readManifest: async () => undefined, - readChannelMap: async () => ({}), - writeChannelMap: async () => { writes += 1; }, - }), - /is not published/, - ); - assert.equal(writes, 0); -}); - -test('rollback rejects an expired target build', async () => { - let writes = 0; - await assert.rejects( - rollbackChannel({ - channel: 'v1', - buildId: '1.7.0-0123456789ab', - ctx: {}, - logger: silentLogger, - readManifest: async () => ({ - buildId: '1.7.0-0123456789ab', - channel: 'v1', - protocolMajor: 1, - expiresAt: new Date(Date.now() - 1000).toISOString(), - }), - readChannelMap: async () => ({}), - writeChannelMap: async () => { writes += 1; }, - }), - /expired at/, - ); - assert.equal(writes, 0); -}); - -test('rollback warns but proceeds for a near-expiry target build', async () => { - const warnings = []; - const writes = []; - const result = await rollbackChannel({ - channel: 'v1', - buildId: '1.7.0-0123456789ab', - ctx: {}, - logger: { log() {}, warn(msg) { warnings.push(msg); } }, - readManifest: async () => ({ - buildId: '1.7.0-0123456789ab', - channel: 'v1', - protocolMajor: 1, - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - }), - readChannelMap: async () => ({ v1: '1.6.0-aaaaaaaaaaaa' }), - writeChannelMap: async (_ctx, channels) => writes.push({ ...channels }), - }); - assert.equal(result.changed, true); - assert.equal(writes.length, 1); - assert.equal(warnings.length, 1); - assert.match(warnings[0], /expires/); -}); - -test('rollback rejects a build from another major channel', async () => { - let writes = 0; - await assert.rejects( - rollbackChannel({ - channel: 'v1', - buildId: '2.0.0-0123456789ab', - ctx: {}, - logger: silentLogger, - readManifest: async () => ({ - buildId: '2.0.0-0123456789ab', - channel: 'v2', - protocolMajor: 2, - }), - readChannelMap: async () => ({}), - writeChannelMap: async () => { writes += 1; }, - }), - /on v2 with protocol 2; expected v1 with protocol 1/, - ); - assert.equal(writes, 0); -}); diff --git a/apps/widget-cdn/scripts/rollback-r2.mjs b/apps/widget-cdn/scripts/rollback-r2.mjs deleted file mode 100644 index 22ef52059b..0000000000 --- a/apps/widget-cdn/scripts/rollback-r2.mjs +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env node -// Point a rolling channel at a previously-published build. The target's -// signed manifest must identify both the requested build and channel before -// the mutable channels.json pointer is changed. - -import { resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { - makeClient, - readJsonObject, - readChannels, - writeChannels, -} from './r2-lib.mjs'; -import { isValidBuildId } from './build-id.mjs'; -import { WIDGET_PROTOCOL_MAJOR, widgetProtocolMajorOf } from '@layerswap/widget-types'; - -export async function rollbackChannel(options) { - const { - channel, - buildId, - logger = console, - readManifest = readJsonObject, - readChannelMap = readChannels, - writeChannelMap = writeChannels, - } = options; - - if (!channel || !/^v\d+$/.test(channel) || !isValidBuildId(buildId)) { - throw new Error('[rollback-r2] invalid channel or buildId'); - } - - const ctx = options.ctx ?? makeClient(); - const manifestKey = `${buildId}/manifest.json`; - const manifest = await readManifest(ctx, manifestKey); - if (!manifest) { - throw new Error( - `[rollback-r2] build ${buildId} is not published (no ${manifestKey} in bucket).`, - ); - } - const protocolMajor = widgetProtocolMajorOf(manifest); - if ( - manifest.buildId !== buildId - || manifest.channel !== channel - || protocolMajor !== WIDGET_PROTOCOL_MAJOR - ) { - throw new Error( - `[rollback-r2] ${buildId} identifies build ${manifest.buildId ?? '(unknown)'} ` - + `on ${manifest.channel ?? '(unknown)'} with protocol ` - + `${String(protocolMajor)}; expected ${channel} with protocol ` - + `${WIDGET_PROTOCOL_MAJOR}.`, - ); - } - - // Verifying loaders fail closed on manifests past `expiresAt` (replay - // protection), so pointing the channel at an expired build would brick - // every new mount. Re-publish (re-sign) the target build instead. - if (typeof manifest.expiresAt === 'string') { - const expiresMs = Date.parse(manifest.expiresAt); - const dayMs = 24 * 60 * 60 * 1000; - if (!Number.isNaN(expiresMs) && expiresMs <= Date.now()) { - throw new Error( - `[rollback-r2] build ${buildId} expired at ${manifest.expiresAt} — loaders will refuse it. ` - + 'Re-publish that build (re-signing refreshes its validity window) and retry.', - ); - } - if (!Number.isNaN(expiresMs) && expiresMs - Date.now() < 3 * dayMs) { - logger.warn( - `[rollback-r2] warning: build ${buildId} expires ${manifest.expiresAt} (<3 days). ` - + 'Plan a re-publish before then or new mounts will start failing.', - ); - } - } else { - logger.warn( - `[rollback-r2] warning: build ${buildId} has no expiresAt — verifying loaders reject ` - + 'manifests without a validity window. Re-publish it with a current pipeline.', - ); - } - - const channels = await readChannelMap(ctx); - const previous = channels[channel]; - if (previous === buildId) { - logger.log(`[rollback-r2] channel ${channel} already points at ${buildId} — nothing to do.`); - return { changed: false, previous }; - } - channels[channel] = buildId; - await writeChannelMap(ctx, channels); - logger.log(`[rollback-r2] channel ${channel}: ${previous ?? '(none)'} → ${buildId}`); - logger.log('[rollback-r2] propagates within ~60s (channel redirect cache).'); - return { changed: true, previous }; -} - -const entryUrl = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined; -if (entryUrl === import.meta.url) { - const [, , channel, buildId] = process.argv; - if (!channel || !/^v\d+$/.test(channel) || !isValidBuildId(buildId)) { - console.error('usage: node scripts/rollback-r2.mjs '); - console.error(' e.g. node scripts/rollback-r2.mjs v1 1.4.0-abc123def456'); - process.exitCode = 1; - } else { - try { - await rollbackChannel({ channel, buildId }); - } catch (err) { - console.error(err instanceof Error ? err.message : err); - process.exitCode = 1; - } - } -} diff --git a/apps/widget-cdn/worker/src/index.ts b/apps/widget-cdn/worker/src/index.ts deleted file mode 100644 index f0602bd394..0000000000 --- a/apps/widget-cdn/worker/src/index.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Layerswap widget CDN — Cloudflare Worker edge in front of an R2 bucket. - * - * Storage model (set up by `scripts/deploy-r2.mjs`): - * - * R2 bucket - * ├── 1.5.0-abc123def456/ ← immutable, write-once build (buildId = - * │ │ widget version + git sha, see build-id.mjs) - * │ ├── manifest.json ← signed; describes this exact build - * │ ├── remoteEntry.js - * ├── 1.5.0-fedcba654321/ ← next build, also immutable - * ├── assets/ ← stable, content-addressed namespace shared - * │ └── ..js across builds for browser/R2 cache reuse - * └── channels.json ← the ONLY mutable object: - * { "v1": "1.5.0-abc123def456" } - * - * This Worker does two things: - * - * 1. Rolling channel → 302 redirect. A request for `/v1/manifest.json` - * reads `channels.json`, finds the current buildId for `v1`, and - * redirects to `//manifest.json`. The loader follows the - * redirect and resolves the relative `remoteEntry` against the FINAL - * URL, so the remote anchors at the immutable `//` path. Its - * runtime loads content-hashed chunks from `/assets/`. Rollback = flip - * `channels.json`; it propagates within the redirect's 60s cache window. - * - * 2. Immutable artifact serving. Everything under a build directory is - * served from R2 with `immutable` caching and permissive CORS (the widget - * is fetched cross-origin from integrators' pages, and its chunks load - * with `crossorigin="anonymous"` for SRI). - */ - -interface Env { - BUCKET: R2Bucket; -} - -const CORS_ORIGIN = '*'; -const CHANNELS_KEY = 'channels.json'; - -// Matches a rolling major channel segment: v1, v2, … -const CHANNEL_RE = /^v\d+$/; -// Keep in sync with scripts/build-id.mjs. Build IDs are one safe route -// segment and can never shadow a rolling channel or the shared asset prefix. -const BUILD_ID_RE = /^(?!v\d+$)(?!assets$)[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/; - -const SECURITY_HEADERS: Record = { - 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload', - 'X-Content-Type-Options': 'nosniff', - 'X-Frame-Options': 'DENY', -}; - -const CORS_HEADERS: Record = { - 'Access-Control-Allow-Origin': CORS_ORIGIN, - 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', - 'Access-Control-Allow-Headers': '*', - 'Access-Control-Max-Age': '86400', -}; - -function baseHeaders(extra?: Record): Headers { - const h = new Headers({ ...SECURITY_HEADERS, ...CORS_HEADERS }); - if (extra) for (const [k, v] of Object.entries(extra)) h.set(k, v); - return h; -} - -const CONTENT_TYPES: Record = { - js: 'text/javascript; charset=utf-8', - mjs: 'text/javascript; charset=utf-8', - css: 'text/css; charset=utf-8', - json: 'application/json; charset=utf-8', - map: 'application/json; charset=utf-8', -}; - -function contentTypeFor(key: string): string { - const ext = key.split('.').pop()?.toLowerCase() ?? ''; - return CONTENT_TYPES[ext] ?? 'application/octet-stream'; -} - -export default { - async fetch(request: Request, env: Env): Promise { - if (request.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: baseHeaders() }); - } - if (request.method !== 'GET' && request.method !== 'HEAD') { - return new Response('Method Not Allowed', { status: 405, headers: baseHeaders() }); - } - - const url = new URL(request.url); - const path = url.pathname.replace(/^\/+/, ''); - const segments = path.split('/'); - const first = segments[0] ?? ''; - - // The channel pointer is an internal control file — never serve it. - if (path === CHANNELS_KEY) { - return new Response('Not Found', { status: 404, headers: baseHeaders() }); - } - - // (1) Rolling channel → 302 to the current immutable build. - if (CHANNEL_RE.test(first)) { - const buildId = await currentBuildId(env, first); - if (!buildId) { - return new Response(`Unknown channel: ${first}`, { status: 404, headers: baseHeaders() }); - } - const rest = segments.slice(1).join('/'); // e.g. "manifest.json" - const location = `/${buildId}/${rest}`; - return new Response(null, { - status: 302, - headers: baseHeaders({ - Location: location, - // Short cache so a channel flip (roll-forward or rollback) - // is picked up within a minute. The target it points at is - // immutable, so this is the only thing that ever goes stale. - 'Cache-Control': 'public, max-age=60, must-revalidate', - }), - }); - } - - // (2) Immutable artifact under a build directory. - return serveObject(env, path, request.method === 'HEAD'); - }, -}; - -/** Read `channels.json` and return the current buildId for a major channel. */ -async function currentBuildId(env: Env, channel: string): Promise { - const obj = await env.BUCKET.get(CHANNELS_KEY); - if (!obj) return null; - try { - const channels = (await obj.json()) as Record; - const v = channels[channel]; - return typeof v === 'string' && BUILD_ID_RE.test(v) ? v : null; - } catch { - return null; - } -} - -async function serveObject(env: Env, key: string, headOnly: boolean): Promise { - const object = await env.BUCKET.get(key); - if (!object) { - return new Response('Not Found', { status: 404, headers: baseHeaders() }); - } - - // Build directories are write-once and /assets/ keys are content-hashed, - // so every externally served object is immutable. - const headers = baseHeaders({ - 'Content-Type': object.httpMetadata?.contentType || contentTypeFor(key), - 'Cache-Control': 'public, max-age=31536000, immutable', - ETag: object.httpEtag, - }); - if (object.size != null) headers.set('Content-Length', String(object.size)); - - return new Response(headOnly ? null : object.body, { status: 200, headers }); -} diff --git a/apps/widget-cdn/worker/tsconfig.json b/apps/widget-cdn/worker/tsconfig.json deleted file mode 100644 index a86714df25..0000000000 --- a/apps/widget-cdn/worker/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "es2022", - "module": "es2022", - "moduleResolution": "bundler", - "lib": ["es2022"], - "types": ["@cloudflare/workers-types"], - "strict": true, - "noEmit": true, - "skipLibCheck": true, - "esModuleInterop": true - }, - "include": ["src/**/*.ts"] -} diff --git a/apps/widget-cdn/worker/wrangler.toml b/apps/widget-cdn/worker/wrangler.toml deleted file mode 100644 index d24c8d69d6..0000000000 --- a/apps/widget-cdn/worker/wrangler.toml +++ /dev/null @@ -1,23 +0,0 @@ -name = "layerswap-widget-cdn" -main = "src/index.ts" -compatibility_date = "2024-09-23" - -# The R2 bucket holding the immutable per-version builds plus the single -# mutable `channels.json` pointer. Create it once with: -# wrangler r2 bucket create layerswap-widget-cdn -[[r2_buckets]] -binding = "BUCKET" -bucket_name = "layerswap-widget-cdn" - -# Serve the CDN from your own domain. Configure the custom domain in the -# Cloudflare dashboard (Workers & Pages → this Worker → Triggers → Custom -# Domains) or uncomment below once the zone is on Cloudflare: -# -# routes = [ -# { pattern = "cdn.layerswap.io", custom_domain = true }, -# ] -# -# Until then the Worker is reachable at its *.workers.dev subdomain. - -[observability] -enabled = true diff --git a/packages/widget/js/src/manifest.ts b/packages/widget/js/src/manifest.ts index 5fa6694de6..20fc9de0ca 100644 --- a/packages/widget/js/src/manifest.ts +++ b/packages/widget/js/src/manifest.ts @@ -37,7 +37,7 @@ export type Manifest = { * ISO-8601 end of the manifest's validity window. Replay protection: a * signature and SRI prove authenticity and byte integrity but not * freshness — without an enforced expiry, an attacker who can replay - * CDN/R2 responses could serve an older valid build indefinitely, + * CDN/storage responses could serve an older valid build indefinitely, * reviving a fixed vulnerability or bypassing a newer kill switch. * * Availability policy (explicit, so an outage is never a reason to accept @@ -104,11 +104,11 @@ export const MANIFEST_VERIFY_PUBLIC_KEY_SPKI_B64 = * reads it directly. Points at the rolling `v1` channel, so hosts auto-receive * forward-compatible widget updates without a redeploy. * - * Currently the Cloudflare Worker's `*.workers.dev` subdomain. When the - * `cdn.layerswap.io` custom domain is wired in `apps/widget-cdn/worker/ - * wrangler.toml`, update this constant to `https://cdn.layerswap.io/v1/ - * manifest.json` and publish a new `@layerswap/widget-js` — integrators pick - * up the new origin transitively via npm. + * Currently the Azure Blob Storage endpoint. When a custom domain such as + * `cdn.layerswap.io` fronts the storage account (e.g. via Azure Front Door), + * update this constant to `https://cdn.layerswap.io/v1/manifest.json` and + * publish a new `@layerswap/widget-js` — integrators pick up the new origin + * transitively via npm. * * The channel major is the shared widget protocol major. A breaking `/v2/` * ships with loader package major 2. Exact build pinning is intentionally not diff --git a/packages/widget/react/README.md b/packages/widget/react/README.md index 8fa740f0d4..744b3977e7 100644 --- a/packages/widget/react/README.md +++ b/packages/widget/react/README.md @@ -166,7 +166,7 @@ accordingly. ## Recommended Content Security Policy The widget is served from the fixed origin baked into this package release — -currently `https://layerswap-widget-cdn.layerswapcdn.workers.dev` (the +currently `https://layerswapcdntest.blob.core.windows.net` (the `WIDGET_MANIFEST_URL` in `@layerswap/widget-js`; if a future release moves to a custom domain such as `cdn.layerswap.io`, this section moves with it). A tight CSP that allowlists exactly that origin plus the LayerSwap endpoints @@ -176,8 +176,8 @@ compromised: ``` Content-Security-Policy: default-src 'self'; - script-src 'self' https://layerswap-widget-cdn.layerswapcdn.workers.dev; - connect-src 'self' https://layerswap-widget-cdn.layerswapcdn.workers.dev + script-src 'self' https://layerswapcdntest.blob.core.windows.net; + connect-src 'self' https://layerswapcdntest.blob.core.windows.net https://api.layerswap.io https://layerswap.io https://*.walletconnect.com https://*.walletconnect.org; style-src 'self' 'unsafe-inline'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 649bacbaaf..edc29f6c5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,7 +160,7 @@ importers: version: 0.9.0(@types/react@19.2.3)(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.3)) '@flags-sdk/vercel': specifier: ^1.4.3 - version: 1.4.3(@aws-sdk/credential-provider-web-identity@3.972.66)(flags@4.2.0(next@15.5.22(@types/node@24.10.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@15.5.22(@types/node@24.10.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + version: 1.4.3(flags@4.2.0(next@15.5.22(@types/node@24.10.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@15.5.22(@types/node@24.10.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@layerswap/wallet-evm': specifier: workspace:^ version: link:../../packages/wallets/evm @@ -422,12 +422,6 @@ importers: specifier: 'catalog:' version: 4.5.7(@types/react@19.2.3)(react@19.2.3) devDependencies: - '@aws-sdk/client-s3': - specifier: ^3.700.0 - version: 3.1092.0 - '@cloudflare/workers-types': - specifier: ^4.20241127.0 - version: 4.20260702.1 '@module-federation/enhanced': specifier: ^0.9.0 version: 0.9.1(@rspack/core@1.7.12(@swc/helpers@0.5.18))(bufferutil@4.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10) @@ -455,9 +449,6 @@ importers: typescript: specifier: ^5.9.2 version: 5.9.3 - wrangler: - specifier: ^4.0.0 - version: 4.114.0(@cloudflare/workers-types@4.20260702.1)(bufferutil@4.1.0)(utf-8-validate@5.0.10) apps/widget-playground: dependencies: @@ -720,7 +711,7 @@ importers: version: 0.33.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) '@walletconnect/ethereum-provider': specifier: 2.21.8 - version: 2.21.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + version: 2.21.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) ethers: specifier: 5.7.2 version: 5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10) @@ -848,7 +839,7 @@ importers: version: 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-wallets': specifier: ^0.19.38 - version: 0.19.38(@babel/runtime@7.28.4)(@solana/sysvars@2.3.0(typescript@5.9.3))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bs58@6.0.0)(bufferutil@4.1.0)(react@19.2.3)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76) + version: 0.19.38(@babel/runtime@7.28.4)(@solana/sysvars@2.3.0(typescript@5.9.3))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bs58@6.0.0)(bufferutil@4.1.0)(react@19.2.3)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76) '@solana/wallet-standard-wallet-adapter-base': specifier: ^1.1.4 version: 1.1.4(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(bs58@6.0.0) @@ -860,16 +851,16 @@ importers: version: 1.1.1 '@walletconnect/ethereum-provider': specifier: 2.21.8 - version: 2.21.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + version: 2.21.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/types': specifier: 'catalog:' - version: 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + version: 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/universal-provider': specifier: 2.21.8 - version: 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + version: 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/utils': specifier: 'catalog:' - version: 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + version: 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) bs58: specifier: ^6.0.0 version: 6.0.0 @@ -1166,78 +1157,6 @@ packages: url-join: 5.0.0 zod: 4.0.5 - '@aws-sdk/checksums@3.1000.19': - resolution: {integrity: sha512-Hc4N100RdkuWshKBnhPzmpdftfi9mCLz+OHFELHM1QIgMH4QRUUWyWgfiebta/YX2Bd62wTcm3EqAP8TeXv0gA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/client-s3@3.1092.0': - resolution: {integrity: sha512-NfcptdANQM1IgUT8QITKBN+PZPjshm5FyLKKjotEwscsDQGik4iDdLgwFYJSTlGoREv26Tf97WHpL7IZ3HF9nA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/core@3.976.0': - resolution: {integrity: sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-env@3.972.60': - resolution: {integrity: sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-http@3.972.62': - resolution: {integrity: sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-ini@3.973.5': - resolution: {integrity: sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-login@3.972.67': - resolution: {integrity: sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-node@3.972.71': - resolution: {integrity: sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-process@3.972.60': - resolution: {integrity: sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-sso@3.973.4': - resolution: {integrity: sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-web-identity@3.972.66': - resolution: {integrity: sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-sdk-s3@3.972.65': - resolution: {integrity: sha512-udwNhRfDTfCB98mAHjjgsnKQlxygB4e0X+Obne/XjJpvVsF0YCQC8ZErd/8Z6IPoLQjtiKHzwqEDbZiLrJEnOg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/nested-clients@3.997.34': - resolution: {integrity: sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/signature-v4-multi-region@3.996.41': - resolution: {integrity: sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/token-providers@3.1092.0': - resolution: {integrity: sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/types@3.974.2': - resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/xml-builder@3.972.36': - resolution: {integrity: sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==} - engines: {node: '>=20.0.0'} - - '@aws/lambda-invoke-store@0.3.0': - resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} - engines: {node: '>=18.0.0'} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -1454,62 +1373,12 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@cloudflare/kv-asset-handler@0.5.0': - resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} - engines: {node: '>=22.0.0'} - - '@cloudflare/unenv-preset@2.16.1': - resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} - peerDependencies: - unenv: 2.0.0-rc.24 - workerd: '>1.20260305.0 <2.0.0-0' - peerDependenciesMeta: - workerd: - optional: true - - '@cloudflare/workerd-darwin-64@1.20260722.1': - resolution: {integrity: sha512-vZOP8vIS3NwnuaO+gz0FZ7kIGeiO3bZmxV35Ph9zOXKSREhDFlH7wQ7mkCdhW3O4jnXsew+XT7b+DNEI2CcJGQ==} - engines: {node: '>=16'} - cpu: [x64] - os: [darwin] - - '@cloudflare/workerd-darwin-arm64@1.20260722.1': - resolution: {integrity: sha512-EmIQymihDq6WNdER4+LF8Qn80yqayBUpJ+tkOO7wmY8pmgfyXjIUFNXotl21AHovTeu2seR7HdVUgeN/BilCWw==} - engines: {node: '>=16'} - cpu: [arm64] - os: [darwin] - - '@cloudflare/workerd-linux-64@1.20260722.1': - resolution: {integrity: sha512-jvZ3k9fxcnEn04s80CgIYxQfpOyAiz/8qC42DP8EBa9tR27qWyg9wmm31zIobVlrgBZn/+8NfdP73avRGcQOjQ==} - engines: {node: '>=16'} - cpu: [x64] - os: [linux] - - '@cloudflare/workerd-linux-arm64@1.20260722.1': - resolution: {integrity: sha512-BOSB55SMNdy+DA5uj2WirgiNanpHGis5PVvXH1wSfvjRKr4JGgWK+EZzxz0RFUo6QjjQQC/NimEzNZ7va7jmKg==} - engines: {node: '>=16'} - cpu: [arm64] - os: [linux] - - '@cloudflare/workerd-windows-64@1.20260722.1': - resolution: {integrity: sha512-sYM8YgUpKnRz2xjvdJLX1Ojzoi4MlA4gk8WTTExhGydjYB2UTs5NIbv0ZmpKgMoK9io3ixgmiW56ZnTbcWOdiA==} - engines: {node: '>=16'} - cpu: [x64] - os: [win32] - - '@cloudflare/workers-types@4.20260702.1': - resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} - '@coinbase/wallet-sdk@3.9.3': resolution: {integrity: sha512-N/A2DRIf0Y3PHc1XAMvbBUu4zisna6qAdqABMZwBMNEfWrXpAwx16pZGkYCLGE+Rvv1edbcB2LYDRnACNcmCiw==} '@coinbase/wallet-sdk@4.3.0': resolution: {integrity: sha512-T3+SNmiCw4HzDm4we9wCHCxlP0pqCiwKe4sOwPH3YAK2KSKjxPRydKu6UQJrdONFVLG7ujXvbd/6ZqmvJb8rkw==} - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} - '@discoveryjs/json-ext@0.5.7': resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} @@ -2179,284 +2048,142 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.2': - resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} - engines: {node: '>=20.9.0'} - cpu: [arm64] - os: [darwin] - '@img/sharp-darwin-arm64@0.35.3': resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.2': - resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} - engines: {node: '>=20.9.0'} - cpu: [x64] - os: [darwin] - '@img/sharp-darwin-x64@0.35.3': resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.2': - resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} - engines: {node: '>=20.9.0'} - os: [freebsd] - '@img/sharp-freebsd-wasm32@0.35.3': resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.1': - resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} - cpu: [arm64] - os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.3.2': resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.1': - resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} - cpu: [x64] - os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.2': resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.1': - resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} - cpu: [arm64] - os: [linux] - '@img/sharp-libvips-linux-arm64@1.3.2': resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linux-arm@1.3.1': - resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} - cpu: [arm] - os: [linux] - '@img/sharp-libvips-linux-arm@1.3.2': resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-ppc64@1.3.1': - resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} - cpu: [ppc64] - os: [linux] - '@img/sharp-libvips-linux-ppc64@1.3.2': resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} cpu: [ppc64] os: [linux] - '@img/sharp-libvips-linux-riscv64@1.3.1': - resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} - cpu: [riscv64] - os: [linux] - '@img/sharp-libvips-linux-riscv64@1.3.2': resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} cpu: [riscv64] os: [linux] - '@img/sharp-libvips-linux-s390x@1.3.1': - resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} - cpu: [s390x] - os: [linux] - '@img/sharp-libvips-linux-s390x@1.3.2': resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} cpu: [s390x] os: [linux] - '@img/sharp-libvips-linux-x64@1.3.1': - resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} - cpu: [x64] - os: [linux] - '@img/sharp-libvips-linux-x64@1.3.2': resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} cpu: [x64] os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.3.1': - resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} - cpu: [arm64] - os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.3.1': - resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} - cpu: [x64] - os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.3.2': resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} cpu: [x64] os: [linux] - '@img/sharp-linux-arm64@0.35.2': - resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} - engines: {node: '>=20.9.0'} - cpu: [arm64] - os: [linux] - '@img/sharp-linux-arm64@0.35.3': resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linux-arm@0.35.2': - resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} - engines: {node: '>=20.9.0'} - cpu: [arm] - os: [linux] - '@img/sharp-linux-arm@0.35.3': resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] - '@img/sharp-linux-ppc64@0.35.2': - resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} - engines: {node: '>=20.9.0'} - cpu: [ppc64] - os: [linux] - '@img/sharp-linux-ppc64@0.35.3': resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] - '@img/sharp-linux-riscv64@0.35.2': - resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} - engines: {node: '>=20.9.0'} - cpu: [riscv64] - os: [linux] - '@img/sharp-linux-riscv64@0.35.3': resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] - '@img/sharp-linux-s390x@0.35.2': - resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} - engines: {node: '>=20.9.0'} - cpu: [s390x] - os: [linux] - '@img/sharp-linux-s390x@0.35.3': resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] - '@img/sharp-linux-x64@0.35.2': - resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} - engines: {node: '>=20.9.0'} - cpu: [x64] - os: [linux] - '@img/sharp-linux-x64@0.35.3': resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-linuxmusl-arm64@0.35.2': - resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} - engines: {node: '>=20.9.0'} - cpu: [arm64] - os: [linux] - '@img/sharp-linuxmusl-arm64@0.35.3': resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linuxmusl-x64@0.35.2': - resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} - engines: {node: '>=20.9.0'} - cpu: [x64] - os: [linux] - '@img/sharp-linuxmusl-x64@0.35.3': resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-wasm32@0.35.2': - resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} - engines: {node: '>=20.9.0'} - '@img/sharp-wasm32@0.35.3': resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.2': - resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} - engines: {node: '>=20.9.0'} - cpu: [wasm32] - '@img/sharp-webcontainers-wasm32@0.35.3': resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.2': - resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} - engines: {node: '>=20.9.0'} - cpu: [arm64] - os: [win32] - '@img/sharp-win32-arm64@0.35.3': resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.2': - resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} - engines: {node: ^20.9.0} - cpu: [ia32] - os: [win32] - '@img/sharp-win32-ia32@0.35.3': resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.2': - resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} - engines: {node: '>=20.9.0'} - cpu: [x64] - os: [win32] - '@img/sharp-win32-x64@0.35.3': resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} engines: {node: '>=20.9.0'} @@ -2506,9 +2233,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@jsonjoy.com/base64@1.1.2': resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} engines: {node: '>=10.0'} @@ -3099,15 +2823,6 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@poppinss/colors@4.1.6': - resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} - - '@poppinss/dumper@0.6.5': - resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} - - '@poppinss/exception@1.2.3': - resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} - '@posthog/cli@0.7.25': resolution: {integrity: sha512-o+NiX3D7nmXPiY2aOQ7WqzhNyLXsGfpCrha/i8dnBGouP43Pqeyxw+N/H5CM0McH7uMZyEv76JIziPzL91YMJw==} engines: {node: '>=14.14', npm: '>=6'} @@ -4642,34 +4357,6 @@ packages: '@sinclair/typebox@0.33.22': resolution: {integrity: sha512-auUj4k+f4pyrIVf4GW5UKquSZFHJWri06QgARy9C0t9ZTjJLIuNIrr1yl9bWcJWJ1Gz1vOvYN1D+QPaIlNMVkQ==} - '@sindresorhus/is@7.2.0': - resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} - engines: {node: '>=18'} - - '@smithy/core@3.29.7': - resolution: {integrity: sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ==} - engines: {node: '>=18.0.0'} - - '@smithy/credential-provider-imds@4.4.12': - resolution: {integrity: sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q==} - engines: {node: '>=18.0.0'} - - '@smithy/fetch-http-handler@5.6.9': - resolution: {integrity: sha512-EJktha5m5MXCwzdXrlWyqb9UCNHNFKlg+PmTpRsdX3dncJPTiqYleM9OKj2mLgdVJHR01d2tU4alG+z2NdH5rQ==} - engines: {node: '>=18.0.0'} - - '@smithy/node-http-handler@4.9.9': - resolution: {integrity: sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg==} - engines: {node: '>=18.0.0'} - - '@smithy/signature-v4@5.6.8': - resolution: {integrity: sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg==} - engines: {node: '>=18.0.0'} - - '@smithy/types@4.16.1': - resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} - engines: {node: '>=18.0.0'} - '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -5236,9 +4923,6 @@ packages: peerDependencies: '@solana/web3.js': '*' - '@speed-highlight/core@1.2.17': - resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} - '@starknet-io/get-starknet-core@4.0.8': resolution: {integrity: sha512-UCyaO5T+5IZN1qPiQhLPzCSmoy06FU42tZtDmpj0UifSP7vdSMe+KRnkegiikpPJ8wZmel1o26GQkBFoUrHQlQ==} @@ -6599,9 +6283,6 @@ packages: resolution: {integrity: sha512-Igj8YowDu1PRkRsxZA7NVkdFNxH5rKv5cpLxQ0CVXSIA77pVYwCPRQJ2sMew/oneUpfuYRyjG6r8SmmmnbZb1w==} engines: {node: '>= 10'} - blake3-wasm@2.1.5: - resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} - blakejs@1.2.1: resolution: {integrity: sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==} @@ -6934,10 +6615,6 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} - cookies@0.9.1: resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} engines: {node: '>= 0.8'} @@ -7286,9 +6963,6 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} - error-stack-parser-es@1.0.5: - resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} - es-abstract@1.24.1: resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} engines: {node: '>= 0.4'} @@ -8487,10 +8161,6 @@ packages: keyvaluestorage-interface@1.0.0: resolution: {integrity: sha512-8t6Q3TclQ4uZynJY9IGr2+SsIGwK9JHcO6ootkHCGA0CrQCRy+VkouYNO2xicET6b9al7QKzpebNow+gkpCL8g==} - kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} - engines: {node: '>=6'} - koa-compose@4.1.0: resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} @@ -8759,11 +8429,6 @@ packages: resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} hasBin: true - miniflare@4.20260722.0: - resolution: {integrity: sha512-LW6ABMhCx/yIEFBLC/DO4yAhdm2T/G7jp7pr5T2kj895+CCIaHZqpMXdW9O6YE48LcYcCJChwWc8aEs1vpbTXw==} - engines: {node: '>=22.0.0'} - hasBin: true - minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -9265,16 +8930,10 @@ packages: path-to-regexp@0.1.13: resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} - path-to-regexp@6.3.0: - resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pause-stream@0.0.11: resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} @@ -9994,10 +9653,6 @@ packages: sha256-uint8array@0.10.7: resolution: {integrity: sha512-1Q6JQU4tX9NqsDGodej6pkrUVQVNapLZnvkwIhddH/JqzBZF1fSaxSWNY6sziXBE8aEa2twtGkXUrwzGeZCMpQ==} - sharp@0.35.2: - resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} - engines: {node: '>=20.9.0'} - sharp@0.35.3: resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} engines: {node: '>=20.9.0'} @@ -10270,10 +9925,6 @@ packages: resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==} engines: {node: '>=14.0.0'} - supports-color@10.2.2: - resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} - engines: {node: '>=18'} - supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -10564,13 +10215,6 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} - engines: {node: '>=20.18.1'} - - unenv@2.0.0-rc.24: - resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} - unidragger@3.0.1: resolution: {integrity: sha512-RngbGSwBFmqGBWjkaH+yB677uzR95blSQyxq6hYbrQCejH3Mx1nm8DVOuh3M9k2fQyTstWUG5qlgCnNqV/9jVw==} @@ -10972,21 +10616,6 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - workerd@1.20260722.1: - resolution: {integrity: sha512-NycKuc1x2onvsRfGGpM093vRlLFU2zHDAM0+APpccfg4+gZxDGCH27RmdDvkeBuoZyYqgLo3oAfF6re4mvC3vQ==} - engines: {node: '>=16'} - hasBin: true - - wrangler@4.114.0: - resolution: {integrity: sha512-M65P25t5UHA1TIJfgZXDcj+YzVobgKdRguM2QPz0xnxLFuOcuE3ErgllDht0iaho7MS4o0g/Bb4YK2+GT+bibg==} - engines: {node: '>=22.0.0'} - hasBin: true - peerDependencies: - '@cloudflare/workers-types': ^5.20260722.1 - peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true - wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -11089,12 +10718,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - youch-core@0.3.3: - resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} - - youch@4.1.0-beta.10: - resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} - yup@1.6.1: resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==} @@ -11186,171 +10809,6 @@ snapshots: tailwindcss-animate: 1.0.7(tailwindcss@4.0.15) zod: 4.1.11 - '@aws-sdk/checksums@3.1000.19': - dependencies: - '@aws-sdk/core': 3.976.0 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.29.7 - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@aws-sdk/client-s3@3.1092.0': - dependencies: - '@aws-sdk/checksums': 3.1000.19 - '@aws-sdk/core': 3.976.0 - '@aws-sdk/credential-provider-node': 3.972.71 - '@aws-sdk/middleware-sdk-s3': 3.972.65 - '@aws-sdk/signature-v4-multi-region': 3.996.41 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.29.7 - '@smithy/fetch-http-handler': 5.6.9 - '@smithy/node-http-handler': 4.9.9 - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@aws-sdk/core@3.976.0': - dependencies: - '@aws-sdk/types': 3.974.2 - '@aws-sdk/xml-builder': 3.972.36 - '@aws/lambda-invoke-store': 0.3.0 - '@smithy/core': 3.29.7 - '@smithy/signature-v4': 5.6.8 - '@smithy/types': 4.16.1 - bowser: 2.13.1 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-env@3.972.60': - dependencies: - '@aws-sdk/core': 3.976.0 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.29.7 - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-http@3.972.62': - dependencies: - '@aws-sdk/core': 3.976.0 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.29.7 - '@smithy/fetch-http-handler': 5.6.9 - '@smithy/node-http-handler': 4.9.9 - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-ini@3.973.5': - dependencies: - '@aws-sdk/core': 3.976.0 - '@aws-sdk/credential-provider-env': 3.972.60 - '@aws-sdk/credential-provider-http': 3.972.62 - '@aws-sdk/credential-provider-login': 3.972.67 - '@aws-sdk/credential-provider-process': 3.972.60 - '@aws-sdk/credential-provider-sso': 3.973.4 - '@aws-sdk/credential-provider-web-identity': 3.972.66 - '@aws-sdk/nested-clients': 3.997.34 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.29.7 - '@smithy/credential-provider-imds': 4.4.12 - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-login@3.972.67': - dependencies: - '@aws-sdk/core': 3.976.0 - '@aws-sdk/nested-clients': 3.997.34 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.29.7 - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-node@3.972.71': - dependencies: - '@aws-sdk/credential-provider-env': 3.972.60 - '@aws-sdk/credential-provider-http': 3.972.62 - '@aws-sdk/credential-provider-ini': 3.973.5 - '@aws-sdk/credential-provider-process': 3.972.60 - '@aws-sdk/credential-provider-sso': 3.973.4 - '@aws-sdk/credential-provider-web-identity': 3.972.66 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.29.7 - '@smithy/credential-provider-imds': 4.4.12 - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-process@3.972.60': - dependencies: - '@aws-sdk/core': 3.976.0 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.29.7 - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-sso@3.973.4': - dependencies: - '@aws-sdk/core': 3.976.0 - '@aws-sdk/nested-clients': 3.997.34 - '@aws-sdk/token-providers': 3.1092.0 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.29.7 - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-web-identity@3.972.66': - dependencies: - '@aws-sdk/core': 3.976.0 - '@aws-sdk/nested-clients': 3.997.34 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.29.7 - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@aws-sdk/middleware-sdk-s3@3.972.65': - dependencies: - '@aws-sdk/core': 3.976.0 - '@aws-sdk/signature-v4-multi-region': 3.996.41 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.29.7 - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@aws-sdk/nested-clients@3.997.34': - dependencies: - '@aws-sdk/core': 3.976.0 - '@aws-sdk/signature-v4-multi-region': 3.996.41 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.29.7 - '@smithy/fetch-http-handler': 5.6.9 - '@smithy/node-http-handler': 4.9.9 - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@aws-sdk/signature-v4-multi-region@3.996.41': - dependencies: - '@aws-sdk/types': 3.974.2 - '@smithy/signature-v4': 5.6.8 - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@aws-sdk/token-providers@3.1092.0': - dependencies: - '@aws-sdk/core': 3.976.0 - '@aws-sdk/nested-clients': 3.997.34 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.29.7 - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@aws-sdk/types@3.974.2': - dependencies: - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@aws-sdk/xml-builder@3.972.36': - dependencies: - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@aws/lambda-invoke-store@0.3.0': {} - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -11727,31 +11185,6 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 - '@cloudflare/kv-asset-handler@0.5.0': {} - - '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260722.1)': - dependencies: - unenv: 2.0.0-rc.24 - optionalDependencies: - workerd: 1.20260722.1 - - '@cloudflare/workerd-darwin-64@1.20260722.1': - optional: true - - '@cloudflare/workerd-darwin-arm64@1.20260722.1': - optional: true - - '@cloudflare/workerd-linux-64@1.20260722.1': - optional: true - - '@cloudflare/workerd-linux-arm64@1.20260722.1': - optional: true - - '@cloudflare/workerd-windows-64@1.20260722.1': - optional: true - - '@cloudflare/workers-types@4.20260702.1': {} - '@coinbase/wallet-sdk@3.9.3': dependencies: bn.js: 5.2.3 @@ -11773,10 +11206,6 @@ snapshots: eventemitter3: 5.0.1 preact: 10.28.2 - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - '@discoveryjs/json-ext@0.5.7': {} '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': @@ -12306,9 +11735,9 @@ snapshots: '@emurgo/cardano-serialization-lib-browser': 13.2.1 '@emurgo/cardano-serialization-lib-nodejs': 13.2.0 - '@flags-sdk/vercel@1.4.3(@aws-sdk/credential-provider-web-identity@3.972.66)(flags@4.2.0(next@15.5.22(@types/node@24.10.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@15.5.22(@types/node@24.10.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + '@flags-sdk/vercel@1.4.3(flags@4.2.0(next@15.5.22(@types/node@24.10.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@15.5.22(@types/node@24.10.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: - '@vercel/flags-core': 1.5.2(@aws-sdk/credential-provider-web-identity@3.972.66)(next@15.5.22(@types/node@24.10.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@vercel/flags-core': 1.5.2(next@15.5.22(@types/node@24.10.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) flags: 4.2.0(next@15.5.22(@types/node@24.10.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) transitivePeerDependencies: - '@aws-sdk/credential-provider-web-identity' @@ -12589,11 +12018,7 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} - '@img/colour@1.1.0': {} - - '@img/sharp-darwin-arm64@0.35.2': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/colour@1.1.0': optional: true '@img/sharp-darwin-arm64@0.35.3': @@ -12601,201 +12026,102 @@ snapshots: '@img/sharp-libvips-darwin-arm64': 1.3.2 optional: true - '@img/sharp-darwin-x64@0.35.2': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.1 - optional: true - '@img/sharp-darwin-x64@0.35.3': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.3.2 optional: true - '@img/sharp-freebsd-wasm32@0.35.2': - dependencies: - '@img/sharp-wasm32': 0.35.2 - optional: true - '@img/sharp-freebsd-wasm32@0.35.3': dependencies: '@img/sharp-wasm32': 0.35.3 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.1': - optional: true - '@img/sharp-libvips-darwin-arm64@1.3.2': optional: true - '@img/sharp-libvips-darwin-x64@1.3.1': - optional: true - '@img/sharp-libvips-darwin-x64@1.3.2': optional: true - '@img/sharp-libvips-linux-arm64@1.3.1': - optional: true - '@img/sharp-libvips-linux-arm64@1.3.2': optional: true - '@img/sharp-libvips-linux-arm@1.3.1': - optional: true - '@img/sharp-libvips-linux-arm@1.3.2': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.1': - optional: true - '@img/sharp-libvips-linux-ppc64@1.3.2': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.1': - optional: true - '@img/sharp-libvips-linux-riscv64@1.3.2': optional: true - '@img/sharp-libvips-linux-s390x@1.3.1': - optional: true - '@img/sharp-libvips-linux-s390x@1.3.2': optional: true - '@img/sharp-libvips-linux-x64@1.3.1': - optional: true - '@img/sharp-libvips-linux-x64@1.3.2': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.1': - optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.1': - optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.2': optional: true - '@img/sharp-linux-arm64@0.35.2': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.1 - optional: true - '@img/sharp-linux-arm64@0.35.3': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.3.2 optional: true - '@img/sharp-linux-arm@0.35.2': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.1 - optional: true - '@img/sharp-linux-arm@0.35.3': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.3.2 optional: true - '@img/sharp-linux-ppc64@0.35.2': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.1 - optional: true - '@img/sharp-linux-ppc64@0.35.3': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.3.2 optional: true - '@img/sharp-linux-riscv64@0.35.2': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.1 - optional: true - '@img/sharp-linux-riscv64@0.35.3': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.3.2 optional: true - '@img/sharp-linux-s390x@0.35.2': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.1 - optional: true - '@img/sharp-linux-s390x@0.35.3': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.3.2 optional: true - '@img/sharp-linux-x64@0.35.2': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.1 - optional: true - '@img/sharp-linux-x64@0.35.3': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.3.2 optional: true - '@img/sharp-linuxmusl-arm64@0.35.2': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 - optional: true - '@img/sharp-linuxmusl-arm64@0.35.3': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 optional: true - '@img/sharp-linuxmusl-x64@0.35.2': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.1 - optional: true - '@img/sharp-linuxmusl-x64@0.35.3': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.3.2 optional: true - '@img/sharp-wasm32@0.35.2': - dependencies: - '@emnapi/runtime': 1.11.3 - optional: true - '@img/sharp-wasm32@0.35.3': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.2': - dependencies: - '@img/sharp-wasm32': 0.35.2 - optional: true - '@img/sharp-webcontainers-wasm32@0.35.3': dependencies: '@img/sharp-wasm32': 0.35.3 optional: true - '@img/sharp-win32-arm64@0.35.2': - optional: true - '@img/sharp-win32-arm64@0.35.3': optional: true - '@img/sharp-win32-ia32@0.35.2': - optional: true - '@img/sharp-win32-ia32@0.35.3': optional: true - '@img/sharp-win32-x64@0.35.2': - optional: true - '@img/sharp-win32-x64@0.35.3': optional: true @@ -12870,11 +12196,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping@0.3.9': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': dependencies: tslib: 2.8.1 @@ -13711,18 +13032,6 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@poppinss/colors@4.1.6': - dependencies: - kleur: 4.1.5 - - '@poppinss/dumper@0.6.5': - dependencies: - '@poppinss/colors': 4.1.6 - '@sindresorhus/is': 7.2.0 - supports-color: 10.2.2 - - '@poppinss/exception@1.2.3': {} - '@posthog/cli@0.7.25': dependencies: detect-libc: 2.1.2 @@ -15112,11 +14421,11 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-controllers@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 1.13.2(@types/react@19.2.3)(react@19.2.3) viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: @@ -15147,11 +14456,11 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': + '@reown/appkit-controllers@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@walletconnect/universal-provider': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) valtio: 1.13.2(@types/react@19.2.3)(react@19.2.3) viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) transitivePeerDependencies: @@ -15252,12 +14561,12 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-pay@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-pay@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) lit: 3.3.0 valtio: 1.13.2(@types/react@19.2.3)(react@19.2.3) transitivePeerDependencies: @@ -15288,12 +14597,12 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-pay@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': + '@reown/appkit-pay@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) - '@reown/appkit-ui': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) - '@reown/appkit-utils': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) lit: 3.3.0 valtio: 1.13.2(@types/react@19.2.3)(react@19.2.3) transitivePeerDependencies: @@ -15400,12 +14709,12 @@ snapshots: dependencies: buffer: 6.0.3 - '@reown/appkit-scaffold-ui@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-scaffold-ui@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) lit: 3.3.0 transitivePeerDependencies: @@ -15436,12 +14745,12 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-scaffold-ui@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': + '@reown/appkit-scaffold-ui@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) - '@reown/appkit-ui': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) - '@reown/appkit-utils': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) lit: 3.3.0 transitivePeerDependencies: @@ -15544,10 +14853,10 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-ui@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-ui@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) lit: 3.3.0 qrcode: 1.5.3 @@ -15579,10 +14888,10 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-ui@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': + '@reown/appkit-ui@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) lit: 3.3.0 qrcode: 1.5.3 @@ -15684,14 +14993,14 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-utils@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.8 '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 1.13.2(@types/react@19.2.3)(react@19.2.3) viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: @@ -15722,14 +15031,14 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': + '@reown/appkit-utils@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) '@reown/appkit-polyfills': 1.7.8 '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@walletconnect/universal-provider': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) valtio: 1.13.2(@types/react@19.2.3)(react@19.2.3) viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) transitivePeerDependencies: @@ -15847,18 +15156,18 @@ snapshots: - typescript - utf-8-validate - '@reown/appkit@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-pay': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-pay': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.8 - '@reown/appkit-scaffold-ui': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-scaffold-ui': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@walletconnect/types': 2.21.0(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@walletconnect/universal-provider': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/universal-provider': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) bs58: 6.0.0 valtio: 1.13.2(@types/react@19.2.3)(react@19.2.3) viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) @@ -15890,18 +15199,18 @@ snapshots: - utf-8-validate - zod - '@reown/appkit@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': + '@reown/appkit@1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) - '@reown/appkit-pay': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@reown/appkit-pay': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) '@reown/appkit-polyfills': 1.7.8 - '@reown/appkit-scaffold-ui': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) - '@reown/appkit-ui': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) - '@reown/appkit-utils': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@reown/appkit-scaffold-ui': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@walletconnect/types': 2.21.0(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@walletconnect/universal-provider': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@walletconnect/types': 2.21.0(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/universal-provider': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) bs58: 6.0.0 valtio: 1.13.2(@types/react@19.2.3)(react@19.2.3) viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) @@ -15943,7 +15252,7 @@ snapshots: '@reown/appkit-ui': 1.7.8(@types/react@19.2.3)(bufferutil@4.1.0)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) '@reown/appkit-utils': 1.7.8(@types/react@19.2.3)(bufferutil@4.1.0)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@walletconnect/types': 2.21.0(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/types': 2.21.0(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/universal-provider': 2.21.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) bs58: 6.0.0 valtio: 1.13.2(@types/react@19.2.3)(react@18.3.1) @@ -15986,7 +15295,7 @@ snapshots: '@reown/appkit-ui': 1.7.8(@types/react@19.2.3)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) '@reown/appkit-utils': 1.7.8(@types/react@19.2.3)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@walletconnect/types': 2.21.0(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/types': 2.21.0(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/universal-provider': 2.21.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) bs58: 6.0.0 valtio: 1.13.2(@types/react@19.2.3)(react@19.2.3) @@ -16270,41 +15579,6 @@ snapshots: '@sinclair/typebox@0.33.22': {} - '@sindresorhus/is@7.2.0': {} - - '@smithy/core@3.29.7': - dependencies: - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@smithy/credential-provider-imds@4.4.12': - dependencies: - '@smithy/core': 3.29.7 - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@smithy/fetch-http-handler@5.6.9': - dependencies: - '@smithy/core': 3.29.7 - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@smithy/node-http-handler@4.9.9': - dependencies: - '@smithy/core': 3.29.7 - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@smithy/signature-v4@5.6.8': - dependencies: - '@smithy/core': 3.29.7 - '@smithy/types': 4.16.1 - tslib: 2.8.1 - - '@smithy/types@4.16.1': - dependencies: - tslib: 2.8.1 - '@socket.io/component-emitter@3.1.2': {} '@solana-program/compute-budget@0.8.0(@solana/kit@2.3.0(typescript@5.9.3)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': @@ -17013,11 +16287,11 @@ snapshots: '@solana/wallet-standard-util': 1.1.2 '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@solana/wallet-adapter-walletconnect@0.1.21(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@solana/wallet-adapter-walletconnect@0.1.21(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@walletconnect/solana-adapter': 0.0.8(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/solana-adapter': 0.0.8(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -17046,7 +16320,7 @@ snapshots: - utf-8-validate - zod - '@solana/wallet-adapter-wallets@0.19.38(@babel/runtime@7.28.4)(@solana/sysvars@2.3.0(typescript@5.9.3))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bs58@6.0.0)(bufferutil@4.1.0)(react@19.2.3)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)': + '@solana/wallet-adapter-wallets@0.19.38(@babel/runtime@7.28.4)(@solana/sysvars@2.3.0(typescript@5.9.3))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bs58@6.0.0)(bufferutil@4.1.0)(react@19.2.3)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@3.25.76)': dependencies: '@solana/wallet-adapter-alpha': 0.1.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-avana': 0.1.17(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)) @@ -17082,7 +16356,7 @@ snapshots: '@solana/wallet-adapter-trezor': 0.1.6(@solana/sysvars@2.3.0(typescript@5.9.3))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(bufferutil@4.1.0)(tslib@2.8.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-trust': 0.1.17(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-unsafe-burner': 0.1.11(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)) - '@solana/wallet-adapter-walletconnect': 0.1.21(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@solana/wallet-adapter-walletconnect': 0.1.21(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@solana/wallet-adapter-xdefi': 0.1.11(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) transitivePeerDependencies: @@ -17189,8 +16463,6 @@ snapshots: eventemitter3: 5.0.4 uuid: 11.1.1 - '@speed-highlight/core@1.2.17': {} - '@starknet-io/get-starknet-core@4.0.8': dependencies: '@module-federation/runtime': 0.1.21 @@ -18317,9 +17589,9 @@ snapshots: dependencies: execa: 5.1.1 - '@vercel/flags-core@1.5.2(@aws-sdk/credential-provider-web-identity@3.972.66)(next@15.5.22(@types/node@24.10.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + '@vercel/flags-core@1.5.2(next@15.5.22(@types/node@24.10.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: - '@vercel/functions': 3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@vercel/functions': 3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) '@vercel/oidc': 3.5.0 jose: 5.2.1 js-xxhash: 4.0.0 @@ -18329,11 +17601,10 @@ snapshots: - '@aws-sdk/credential-provider-web-identity' - ws - '@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + '@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': dependencies: '@vercel/oidc': 3.8.0 optionalDependencies: - '@aws-sdk/credential-provider-web-identity': 3.972.66 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) '@vercel/oidc@3.5.0': {} @@ -18487,21 +17758,21 @@ snapshots: dependencies: '@wallet-standard/base': 1.1.1 - '@walletconnect/core@2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/utils': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.39.3 events: 3.3.0 @@ -18531,21 +17802,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': + '@walletconnect/core@2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/utils': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.39.3 events: 3.3.0 @@ -18582,13 +17853,13 @@ snapshots: '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/utils': 2.21.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.39.3 @@ -18626,7 +17897,7 @@ snapshots: '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/logger': 3.0.1 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 @@ -18667,18 +17938,18 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/ethereum-provider@2.21.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/ethereum-provider@2.21.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/jsonrpc-http-connection': 1.0.8 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@walletconnect/sign-client': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@walletconnect/universal-provider': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/utils': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/sign-client': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/universal-provider': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -18708,18 +17979,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/ethereum-provider@2.21.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': + '@walletconnect/ethereum-provider@2.21.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': dependencies: - '@reown/appkit': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@reown/appkit': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) '@walletconnect/jsonrpc-http-connection': 1.0.8 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@walletconnect/sign-client': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) - '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@walletconnect/universal-provider': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) - '@walletconnect/utils': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/sign-client': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/universal-provider': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@walletconnect/utils': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -18756,9 +18027,9 @@ snapshots: '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/sign-client': 2.21.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) - '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/universal-provider': 2.21.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) '@walletconnect/utils': 2.21.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) events: 3.3.0 @@ -18797,9 +18068,9 @@ snapshots: '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/sign-client': 2.21.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) - '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/universal-provider': 2.21.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) '@walletconnect/utils': 2.21.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) events: 3.3.0 @@ -18878,11 +18149,11 @@ snapshots: - bufferutil - utf-8-validate - '@walletconnect/keyvaluestorage@1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': + '@walletconnect/keyvaluestorage@1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': dependencies: '@walletconnect/safe-json': 1.0.2 idb-keyval: 6.2.2 - unstorage: 1.17.3(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(idb-keyval@6.2.2) + unstorage: 1.17.3(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(idb-keyval@6.2.2) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -18929,16 +18200,16 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/sign-client@2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/utils': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -18965,16 +18236,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': + '@walletconnect/sign-client@2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': dependencies: - '@walletconnect/core': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@walletconnect/core': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/utils': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -19009,7 +18280,7 @@ snapshots: '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/utils': 2.21.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) events: 3.3.0 transitivePeerDependencies: @@ -19073,13 +18344,13 @@ snapshots: - utf-8-validate - zod - '@walletconnect/solana-adapter@0.0.8(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/solana-adapter@0.0.8(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit': 1.7.8(@types/react@19.2.3)(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/utils': 2.19.0(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.19.0(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) bs58: 6.0.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -19113,12 +18384,12 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/types@2.19.0(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': + '@walletconnect/types@2.19.0(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -19142,12 +18413,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.0(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': + '@walletconnect/types@2.21.0(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -19171,12 +18442,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': + '@walletconnect/types@2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -19205,7 +18476,7 @@ snapshots: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/logger': 3.0.1 events: 3.3.0 transitivePeerDependencies: @@ -19229,18 +18500,18 @@ snapshots: - ioredis - uploadthing - '@walletconnect/universal-provider@2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/utils': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.39.3 events: 3.3.0 transitivePeerDependencies: @@ -19269,18 +18540,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': + '@walletconnect/universal-provider@2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) - '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@walletconnect/sign-client': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/utils': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) es-toolkit: 1.39.3 events: 3.3.0 transitivePeerDependencies: @@ -19316,10 +18587,10 @@ snapshots: '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/logger': 2.1.2 '@walletconnect/sign-client': 2.21.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) - '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/utils': 2.21.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) es-toolkit: 1.39.3 events: 3.3.0 @@ -19349,18 +18620,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.19.0(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.19.0(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.8.0 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.19.0(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/types': 2.19.0(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 detect-browser: 5.3.0 @@ -19393,7 +18664,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@msgpack/msgpack': 3.1.2 '@noble/ciphers': 1.3.0 @@ -19401,12 +18672,12 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 blakejs: 1.2.1 @@ -19440,7 +18711,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': + '@walletconnect/utils@2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11)': dependencies: '@msgpack/msgpack': 3.1.2 '@noble/ciphers': 1.3.0 @@ -19448,12 +18719,12 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 blakejs: 1.2.1 @@ -19495,12 +18766,12 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/types': 2.21.8(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 blakejs: 1.2.1 @@ -19542,7 +18813,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@walletconnect/keyvaluestorage': 1.1.1(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) '@walletconnect/logger': 3.0.1 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 @@ -19974,8 +19245,6 @@ snapshots: node-gyp-build: 4.8.4 readable-stream: 3.6.2 - blake3-wasm@2.1.5: {} - blakejs@1.2.1: {} bn.js@4.12.3: {} @@ -20356,8 +19625,6 @@ snapshots: cookie@0.7.2: {} - cookie@1.1.1: {} - cookies@0.9.1: dependencies: depd: 2.0.0 @@ -20719,8 +19986,6 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 - error-stack-parser-es@1.0.5: {} - es-abstract@1.24.1: dependencies: array-buffer-byte-length: 1.0.2 @@ -22285,8 +21550,6 @@ snapshots: keyvaluestorage-interface@1.0.0: {} - kleur@4.1.5: {} - koa-compose@4.1.0: {} koa-convert@2.0.0: @@ -22558,18 +21821,6 @@ snapshots: mini-svg-data-uri@1.4.4: {} - miniflare@4.20260722.0(bufferutil@4.1.0)(utf-8-validate@5.0.10): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - sharp: 0.35.2 - undici: 7.28.0 - workerd: 1.20260722.1 - ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) - youch: 4.1.0-beta.10 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - minimalistic-assert@1.0.1: {} minimalistic-crypto-utils@1.0.1: {} @@ -23060,12 +22311,8 @@ snapshots: path-to-regexp@0.1.13: {} - path-to-regexp@6.3.0: {} - path-type@4.0.0: {} - pathe@2.0.3: {} - pause-stream@0.0.11: dependencies: through: 2.3.8 @@ -23941,38 +23188,6 @@ snapshots: sha256-uint8array@0.10.7: {} - sharp@0.35.2: - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.8.5 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.2 - '@img/sharp-darwin-x64': 0.35.2 - '@img/sharp-freebsd-wasm32': 0.35.2 - '@img/sharp-libvips-darwin-arm64': 1.3.1 - '@img/sharp-libvips-darwin-x64': 1.3.1 - '@img/sharp-libvips-linux-arm': 1.3.1 - '@img/sharp-libvips-linux-arm64': 1.3.1 - '@img/sharp-libvips-linux-ppc64': 1.3.1 - '@img/sharp-libvips-linux-riscv64': 1.3.1 - '@img/sharp-libvips-linux-s390x': 1.3.1 - '@img/sharp-libvips-linux-x64': 1.3.1 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 - '@img/sharp-libvips-linuxmusl-x64': 1.3.1 - '@img/sharp-linux-arm': 0.35.2 - '@img/sharp-linux-arm64': 0.35.2 - '@img/sharp-linux-ppc64': 0.35.2 - '@img/sharp-linux-riscv64': 0.35.2 - '@img/sharp-linux-s390x': 0.35.2 - '@img/sharp-linux-x64': 0.35.2 - '@img/sharp-linuxmusl-arm64': 0.35.2 - '@img/sharp-linuxmusl-x64': 0.35.2 - '@img/sharp-webcontainers-wasm32': 0.35.2 - '@img/sharp-win32-arm64': 0.35.2 - '@img/sharp-win32-ia32': 0.35.2 - '@img/sharp-win32-x64': 0.35.2 - sharp@0.35.3(@types/node@20.19.28): dependencies: '@img/colour': 1.1.0 @@ -24432,8 +23647,6 @@ snapshots: superstruct@2.0.2: {} - supports-color@10.2.2: {} - supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -24728,12 +23941,6 @@ snapshots: undici-types@7.18.2: {} - undici@7.28.0: {} - - unenv@2.0.0-rc.24: - dependencies: - pathe: 2.0.3 - unidragger@3.0.1: dependencies: ev-emitter: 2.1.2 @@ -24770,7 +23977,7 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - unstorage@1.17.3(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(idb-keyval@6.2.2): + unstorage@1.17.3(@vercel/functions@3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(idb-keyval@6.2.2): dependencies: anymatch: 3.1.3 chokidar: 4.0.3 @@ -24781,7 +23988,7 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.2 optionalDependencies: - '@vercel/functions': 3.7.5(@aws-sdk/credential-provider-web-identity@3.972.66)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@vercel/functions': 3.7.5(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) idb-keyval: 6.2.2 upath@2.0.1: {} @@ -25302,31 +24509,6 @@ snapshots: wordwrap@1.0.0: {} - workerd@1.20260722.1: - optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260722.1 - '@cloudflare/workerd-darwin-arm64': 1.20260722.1 - '@cloudflare/workerd-linux-64': 1.20260722.1 - '@cloudflare/workerd-linux-arm64': 1.20260722.1 - '@cloudflare/workerd-windows-64': 1.20260722.1 - - wrangler@4.114.0(@cloudflare/workers-types@4.20260702.1)(bufferutil@4.1.0)(utf-8-validate@5.0.10): - dependencies: - '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260722.1) - blake3-wasm: 2.1.5 - esbuild: 0.28.1 - miniflare: 4.20260722.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) - path-to-regexp: 6.3.0 - unenv: 2.0.0-rc.24 - workerd: 1.20260722.1 - optionalDependencies: - '@cloudflare/workers-types': 4.20260702.1 - fsevents: 2.3.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -25433,19 +24615,6 @@ snapshots: yocto-queue@0.1.0: {} - youch-core@0.3.3: - dependencies: - '@poppinss/exception': 1.2.3 - error-stack-parser-es: 1.0.5 - - youch@4.1.0-beta.10: - dependencies: - '@poppinss/colors': 4.1.6 - '@poppinss/dumper': 0.6.5 - '@speed-highlight/core': 1.2.17 - cookie: 1.1.1 - youch-core: 0.3.3 - yup@1.6.1: dependencies: property-expr: 2.0.6 From d9fc0e9f8b5b409c0df6f6665c3f5a5f1006ba5e Mon Sep 17 00:00:00 2001 From: babkenmes Date: Tue, 4 Aug 2026 20:33:15 +0400 Subject: [PATCH 13/24] chore(widget-cdn): trigger deploy to verify smoke test after removing stale CDN_BASE_URL variable Co-Authored-By: Claude Fable 5 From 7abe86098353835912d48a9020d952b573e21e95 Mon Sep 17 00:00:00 2001 From: babkenmes Date: Tue, 4 Aug 2026 20:53:55 +0400 Subject: [PATCH 14/24] fix(widget-evm): sync Layerswap chains into host-supplied wagmi config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a host passes an external wagmiConfig, the widget adopted it as-is, so any chain the host didn't declare (hosts typically register only mainnet) was missing from config.chains — wagmi connectors then throw ChainNotConfiguredError on switchChain before ever prompting the wallet, surfacing as "Network switch failed" with no detail. Append Layerswap EVM chains and transports to the adopted config via wagmi's _internal.chains store (host order and transports win), and include the underlying error's shortMessage in the network-switch failure message. Co-Authored-By: Claude Fable 5 --- examples/widget-react-host/src/wagmi.ts | 4 ++ packages/wallets/evm/src/EVMProvider/init.ts | 38 ++++++++++++++++++- .../Swap/Withdraw/Wallet/Common/buttons.tsx | 6 ++- packages/widget/react/README.md | 6 +++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/examples/widget-react-host/src/wagmi.ts b/examples/widget-react-host/src/wagmi.ts index 5a50210580..5a010f29d5 100644 --- a/examples/widget-react-host/src/wagmi.ts +++ b/examples/widget-react-host/src/wagmi.ts @@ -1,6 +1,10 @@ import { http, createConfig } from 'wagmi'; import { mainnet } from 'wagmi/chains'; +// Only the chains the HOST app itself uses need to be declared here. The +// widget appends every Layerswap-supported EVM chain (and transports) to +// this config when it adopts it, so in-widget network switching and +// transfers work on chains not listed below. export const wagmiConfig = createConfig({ chains: [mainnet], // Rely on wagmi's default EIP-6963 multi-injected provider discovery. The diff --git a/packages/wallets/evm/src/EVMProvider/init.ts b/packages/wallets/evm/src/EVMProvider/init.ts index 73cf46f5e0..0e628f6ed8 100644 --- a/packages/wallets/evm/src/EVMProvider/init.ts +++ b/packages/wallets/evm/src/EVMProvider/init.ts @@ -1,4 +1,5 @@ import type { Config } from '@wagmi/core' +import type { Chain, Transport } from 'viem' import type { Network } from '@layerswap/widget/types' import { getEvmChainsConfig } from '../evmUtils/chainConfigs' import { buildEVMConnectors } from './Connectors' @@ -32,6 +33,37 @@ type InitOptions = { let _initialized = false +/** + * Extend the live wagmi config with any Layerswap EVM chains it is missing. + * + * Host apps that hand the widget an external config typically register only + * the chains their own app uses (often just mainnet), but wagmi connectors + * refuse to switch/send on a chain absent from `config.chains` + * (ChainNotConfiguredError) — so without this, "Switch network" fails for + * every Layerswap network the host didn't list. Layerswap chains are appended + * after the host's own via wagmi's `_internal.chains` store, so the host's + * chain order (and default chain) is preserved; transports are added only for + * chains the host didn't configure. `getClient` resolves chains and + * transports at call time, so appended entries are picked up. Idempotent. + */ +function syncLayerswapChains(config: Config, networks: Network[]): void { + const { chains, transports } = getEvmChainsConfig(networks) + const existingIds = new Set(config.chains.map(c => c.id)) + const additions = chains.filter(c => !existingIds.has(c.id)) + if (additions.length === 0) return + config._internal.chains.setState(current => [...current, ...additions] as [Chain, ...Chain[]]) + // Undefined when the host built the config with `client` instead of + // `transports` — client-based configs don't need per-chain transports. + const configTransports = config._internal.transports as Record | undefined + if (configTransports) { + for (const chain of additions) { + if (!(chain.id in configTransports) && transports[chain.id]) { + configTransports[chain.id] = transports[chain.id] + } + } + } +} + /** * One-shot initialization of the EVM wagmi config and store sync. Safe to * call multiple times — subsequent calls are no-ops. @@ -42,7 +74,10 @@ export function initEvmProvider(opts: InitOptions): void { if (_initialized) { // Never drop a host config silently: provideExternalEvmConfig warns // when a different config is already live. - if (externalWagmiConfig) provideExternalEvmConfig(externalWagmiConfig) + if (externalWagmiConfig) { + provideExternalEvmConfig(externalWagmiConfig) + syncLayerswapChains(getEvmConfig(), networks) + } return } @@ -53,6 +88,7 @@ export function initEvmProvider(opts: InitOptions): void { // to whichever config is actually live so there is exactly one // synced wagmi state. provideExternalEvmConfig(externalWagmiConfig) + syncLayerswapChains(getEvmConfig(), networks) attachWagmiSync(getEvmConfig()) _initialized = true return diff --git a/packages/widget/core/src/components/Pages/Swap/Withdraw/Wallet/Common/buttons.tsx b/packages/widget/core/src/components/Pages/Swap/Withdraw/Wallet/Common/buttons.tsx index 7b997b3e72..89da1651ca 100644 --- a/packages/widget/core/src/components/Pages/Swap/Withdraw/Wallet/Common/buttons.tsx +++ b/packages/widget/core/src/components/Pages/Swap/Withdraw/Wallet/Common/buttons.tsx @@ -87,10 +87,14 @@ export const ChangeNetworkMessage: FC<{ data: ActionData, network: string }> = ( /> } else if (data.isError) { + const error = data.error as (Error & { shortMessage?: string, cause?: { shortMessage?: string } }) | null + const reason = error?.cause?.shortMessage ?? error?.shortMessage return } } diff --git a/packages/widget/react/README.md b/packages/widget/react/README.md index 744b3977e7..424618e40e 100644 --- a/packages/widget/react/README.md +++ b/packages/widget/react/README.md @@ -113,6 +113,12 @@ The widget's EVM wallet provider adopts the host's `Config` via nested ``, no second connect flow — the widget reads the host's connected account/chain through the same `Config` instance. +You only need to declare the chains **your own app** uses — the widget +appends every Layerswap-supported EVM chain (with its transports) to the +adopted config at init, so network switching and transfers work on chains +you didn't list. Your chain order is preserved, and your transports win +for chains you did configure. + **Gotcha:** wagmi v2 defaults `multiInjectedProviderDiscovery: true`, which auto-registers an EIP-6963 connector for every announced injected provider alongside any bare connector you declared. With both an From b71fb79c5d7f34857cd5000b915cc5e292cf09ed Mon Sep 17 00:00:00 2001 From: babkenmes Date: Tue, 4 Aug 2026 21:09:35 +0400 Subject: [PATCH 15/24] fix(widget-evm): bypass stale connector chain snapshot when switching on host configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wagmi hands each connector a snapshot of config.chains at setup time (connectorFn({ chains: chains.getState(), ... })). Host apps create their connectors at page load, before syncLayerswapChains appends the Layerswap chains to the adopted config, so connector.switchChain still validated against the stale snapshot and threw ChainNotConfiguredError for any chain the host didn't declare — appending chains to the live store was not enough. For adopted external configs, drive the connector's EIP-1193 provider directly (wallet_switchEthereumChain, wallet_addEthereumChain on 4902, then switch again), mirroring what wagmi's injected connector does after its snapshot check. wagmi state still updates via the chainChanged event, and the subsequent getWalletClient call resolves the chain from the live config.chains getter, which the sync fix populates. Co-Authored-By: Claude Fable 5 --- .../evm/src/service/EvmConnectionService.ts | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/wallets/evm/src/service/EvmConnectionService.ts b/packages/wallets/evm/src/service/EvmConnectionService.ts index c3ee55aa58..ac9539867f 100644 --- a/packages/wallets/evm/src/service/EvmConnectionService.ts +++ b/packages/wallets/evm/src/service/EvmConnectionService.ts @@ -9,6 +9,7 @@ import type { } from '@layerswap/widget/types' import type { RegistryConnector } from '@layerswap/widget/internal' import type { Connector } from 'wagmi' +import { numberToHex, type Chain } from 'viem' import { connect, disconnect, @@ -29,7 +30,7 @@ import { import { evmConnectorNameResolver, resolveEVMWalletConnectorIcon } from '../evmUtils' import { name as PROVIDER_NAME, HIDDEN_WALLETCONNECT_ID } from '../constants' import type { LSConnector } from '../connectors/types' -import { getEvmConfig } from './getEvmConfig' +import { getEvmConfig, isExternalEvmConfig } from './getEvmConfig' import { computeEvmNetworkBuckets, type EvmNetworkBuckets } from './networkBuckets' import { resolveSupportedNetworks } from './resolveSupportedNetworks' import { resolveWallet } from './resolveWallet' @@ -232,8 +233,24 @@ export class EvmConnectionService implements WalletConnectionService { const connector = this.resolveWalletConnector(wallet) if (!connector) throw new Error('Connector not found') + const id = Number(chainId) + + // wagmi connectors validate the target against a snapshot of + // `config.chains` captured at connector setup. With an adopted host + // config, the host created its connectors before syncLayerswapChains + // appended the Layerswap chains, so that snapshot is stale and + // connector.switchChain throws ChainNotConfiguredError even though + // the wallet can switch. Drive the EIP-1193 provider directly instead + // — wagmi still picks up the resulting `chainChanged` event. + if (isExternalEvmConfig()) { + const chain = getEvmConfig().chains.find(c => c.id === id) + if (!chain) throw new Error(`Chain ${id} is not configured`) + await switchChainViaEip1193(connector, chain) + return + } + if (connector.switchChain) { - await connector.switchChain({ chainId: Number(chainId) }) + await connector.switchChain({ chainId: id }) } else { throw new Error('Switch chain method is not available on the connector') } @@ -396,4 +413,43 @@ export class EvmConnectionService implements WalletConnectionService +} + +/** + * Chain switching over the raw EIP-1193 provider, mirroring what wagmi's + * injected connector does after its (snapshot-based) chain lookup: + * `wallet_switchEthereumChain`, falling back to `wallet_addEthereumChain` + * when the wallet doesn't know the chain (error 4902), then switching again + * for wallets that don't auto-switch after adding. + */ +async function switchChainViaEip1193(connector: Connector, chain: Chain): Promise { + const provider = await connector.getProvider() as Eip1193Provider | undefined + if (!provider?.request) throw new Error('Connector provider is not available') + const hexChainId = numberToHex(chain.id) + const requestSwitch = () => provider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: hexChainId }], + }) + try { + await requestSwitch() + } catch (error) { + const code = (error as { code?: number })?.code + ?? (error as { data?: { originalError?: { code?: number } } })?.data?.originalError?.code + if (code !== 4902) throw error + await provider.request({ + method: 'wallet_addEthereumChain', + params: [{ + chainId: hexChainId, + chainName: chain.name, + nativeCurrency: chain.nativeCurrency, + rpcUrls: chain.rpcUrls?.default?.http ?? [], + blockExplorerUrls: chain.blockExplorers?.default ? [chain.blockExplorers.default.url] : undefined, + }], + }) + await requestSwitch() + } +} + export const evmConnectionService = new EvmConnectionService() From 85d9916b60027a8061178f0049540e888de9979d Mon Sep 17 00:00:00 2001 From: babkenmes Date: Wed, 5 Aug 2026 14:58:29 +0400 Subject: [PATCH 16/24] fix(wallet-evm): reset RPC health verdict on chain switch The health store only re-probed on connector/connectedness changes, so after a chain switch the stale unhealthy verdict from the previous chain flashed until the banner's 1.5s poll re-checked the new chain. Reset the snapshot to unknown on chain/connector change (including resubscribe catch-up) and discard in-flight probe results that resolve after the wallet moved to a different chain. Co-Authored-By: Claude Fable 5 --- .../wallets/evm/src/rpcHealthCheckProvider.ts | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/wallets/evm/src/rpcHealthCheckProvider.ts b/packages/wallets/evm/src/rpcHealthCheckProvider.ts index 5ef9ad3c4a..651f1b9324 100644 --- a/packages/wallets/evm/src/rpcHealthCheckProvider.ts +++ b/packages/wallets/evm/src/rpcHealthCheckProvider.ts @@ -63,6 +63,7 @@ function createStore(): RpcHealthCheckStore { const listeners = new Set<() => void>() let lastConnectorId: string | undefined let lastIsConnected = false + let lastChainId: number | undefined let unsubEvm: (() => void) | null = null const setSnapshot = (next: Partial) => { @@ -78,9 +79,16 @@ function createStore(): RpcHealthCheckStore { } const check = async () => { - const { connector, isConnected } = getActiveConnector() + const { connector, isConnected, chainId } = getActiveConnector() if (!connector || !isConnected) return + // The probe is async — the wallet can switch chain (or connector) while it's + // in flight, and a verdict for the old chain must not be stamped onto the new one. + const isCurrent = () => { + const now = getActiveConnector() + return now.connector?.id === connector.id && now.chainId === chainId + } + try { const provider = (await connector.getProvider()) as Eip1193Provider | null if (!provider || typeof provider.request !== 'function') return @@ -95,6 +103,7 @@ function createStore(): RpcHealthCheckStore { 'Wallet RPC timed out', ) const latencyMs = performance.now() - start + if (!isCurrent()) return const tsHex = latestBlock?.timestamp const blockAgeSec = tsHex != null @@ -113,6 +122,7 @@ function createStore(): RpcHealthCheckStore { } setSnapshot({ health: { status: 'healthy', latencyMs, blockAgeSec } satisfies RpcHealth }) } catch (e: any) { + if (!isCurrent()) return // A wallet declining to serve the read method isn't an RPC health signal — // leave status "unknown" so we don't prompt the user to add an RPC. if (isMethodUnsupportedError(e)) { @@ -162,7 +172,7 @@ function createStore(): RpcHealthCheckStore { }) } - // Auto-check when the active connector or connectedness changes. The + // Auto-check when the active connector, connectedness, or chain changes. The // upstream subscription only lives while someone is listening (first // subscriber starts it, last unsubscriber stops it), so consumer // mount/unmount cycles — including StrictMode's — can't leave the store @@ -170,16 +180,24 @@ function createStore(): RpcHealthCheckStore { // also catch up: if a wallet is already connected, check right away. const startAutoCheck = () => { unsubEvm = useEvmStore.subscribe(() => { - const { connector, isConnected } = getActiveConnector() + const { connector, isConnected, chainId } = getActiveConnector() const connectorId = connector?.id - if (connectorId === lastConnectorId && isConnected === lastIsConnected) return + if (connectorId === lastConnectorId && isConnected === lastIsConnected && chainId === lastChainId) return lastConnectorId = connectorId lastIsConnected = isConnected + lastChainId = chainId + // The previous verdict belongs to the old connector/chain — drop back to + // "unknown" (banner hidden) until the fresh probe for this chain resolves. + setSnapshot({ health: { status: undefined } satisfies RpcHealth }) if (connector && isConnected) void check() }) - const { connector, isConnected } = getActiveConnector() + const { connector, isConnected, chainId } = getActiveConnector() + if (connector?.id !== lastConnectorId || chainId !== lastChainId) { + setSnapshot({ health: { status: undefined } satisfies RpcHealth }) + } lastConnectorId = connector?.id lastIsConnected = isConnected + lastChainId = chainId if (connector && isConnected) void check() } From 69d19609630ed25c94154ccc2babd2271fe42d3a Mon Sep 17 00:00:00 2001 From: babkenmes Date: Wed, 5 Aug 2026 19:28:05 +0400 Subject: [PATCH 17/24] fix(widget-cdn): release mount slot on throwing initial render and contain widget crashes A throwing first render in mountRoot() left liveMounts incremented with no handle to destroy, permanently locking out every future mount()/mountDeposit() on the page. Roll the counter back and unmount the root before rethrowing. Also wrap both the initial and update() renders in a MountErrorBoundary so the imperative mount path gets the same render-crash containment the React loader path already has via WidgetErrorBoundary. Co-Authored-By: Claude Fable 5 --- apps/widget-cdn/src/mountRoot.tsx | 36 +++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/widget-cdn/src/mountRoot.tsx b/apps/widget-cdn/src/mountRoot.tsx index bb0fc5ad21..9ff2f8ba01 100644 --- a/apps/widget-cdn/src/mountRoot.tsx +++ b/apps/widget-cdn/src/mountRoot.tsx @@ -1,6 +1,24 @@ -import { ComponentType, createElement } from 'react'; +import { Component, ComponentType, ReactNode, createElement } from 'react'; import { createRoot } from 'react-dom/client'; +// The React loader path wraps the widget in remoteWidgetHost's +// WidgetErrorBoundary; this is the equivalent containment for the imperative +// `mount`/`mountDeposit` exposes, where the host has no React tree of its own +// to catch a render-phase crash. On error the widget unrenders (the handle +// stays valid — destroy() still releases the mount slot). +class MountErrorBoundary extends Component<{ children: ReactNode }, { error: unknown }> { + state = { error: null as unknown }; + static getDerivedStateFromError(error: unknown) { + return { error }; + } + componentDidCatch(error: unknown) { + console.error('[layerswap/widget] widget crashed:', error); + } + render() { + return this.state.error ? null : this.props.children; + } +} + /** Handle returned by the mount exposes for updating props or tearing down. */ export type MountHandle

= { update(props: P): void; @@ -39,17 +57,27 @@ export function mountRoot

( ); } // Claim the slot only after createRoot succeeds — a bad `target` throwing here - // must not leave the counter stuck and lock out every future mount. + // must not leave the counter stuck and lock out every future mount. Same for + // a throwing initial render: release the slot and tear down the root, or the + // caller never gets a handle to destroy() and every future mount is locked out. const root = createRoot(target); liveMounts++; - root.render(createElement(Component, props)); + const renderWidget = (p: P) => + root.render(createElement(MountErrorBoundary, null, createElement(Component, p))); + try { + renderWidget(props); + } catch (error) { + liveMounts--; + root.unmount(); + throw error; + } let destroyed = false; return { update(next: P) { if (destroyed) { throw new Error('[layerswap/widget] update() called on a destroyed widget handle'); } - root.render(createElement(Component, next)); + renderWidget(next); }, destroy() { if (destroyed) return; From f5cda548ff3da41e9e0d3d8783d7fd848ff5c070 Mon Sep 17 00:00:00 2001 From: babkenmes Date: Wed, 5 Aug 2026 19:28:17 +0400 Subject: [PATCH 18/24] ci(widget-cdn): trigger push deploys from dev with path filters The push trigger only watched the feature branch, so deployment would have silently stopped once the branch merged into dev. Trigger on dev (keeping the feature branch until the PR lands), filtered to the paths that actually feed the widget build so unrelated monorepo pushes don't deploy. Approval comes from required reviewers on the widget-cdn-azure-sandbox environment (repo Settings), which the job already targets. Co-Authored-By: Claude Fable 5 --- .github/workflows/widget-cdn-deploy.yml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/widget-cdn-deploy.yml b/.github/workflows/widget-cdn-deploy.yml index c1da1275dd..6ad5da3b4e 100644 --- a/.github/workflows/widget-cdn-deploy.yml +++ b/.github/workflows/widget-cdn-deploy.yml @@ -18,16 +18,25 @@ name: widget-cdn — deploy # admin creates the production Azure identity/storage account and fills in # the environment. Dispatches only from the default branch. # -# A push deploys to the sandbox. Production is workflow_dispatch with -# environment=widget-cdn-production; add required reviewers to that -# environment so the deploy pauses for approval ("Review deployments"). -# -# When this branch moves to the default branch, update the push trigger below. +# A push to dev that touches the widget build graph deploys to the sandbox. +# The sandbox environment has required reviewers, so every push deploy pauses +# for approval ("Review deployments") before touching storage — nothing ships +# unreviewed. Production is workflow_dispatch with +# environment=widget-cdn-production and its own required reviewers. on: push: branches: + - dev + # Pre-merge sandbox deploys — remove once this PR lands on dev. - codex/widget-cdn-azure-sandbox + paths: + - 'apps/widget-cdn/**' + - 'packages/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '.github/workflows/widget-cdn-deploy.yml' + - '.github/scripts/resolve-widget-cdn-env.sh' workflow_dispatch: inputs: environment: From 54247ec4926b611634d3817902e721fb2e33a4e8 Mon Sep 17 00:00:00 2001 From: babkenmes Date: Wed, 5 Aug 2026 19:28:30 +0400 Subject: [PATCH 19/24] perf(widget-core): lazy-load SwapDetails in the Deposit processing step SwapDetails transitively pulls the whole Swap-processing subtree into the Deposit exposes' sync bundle, leaving them at 99.3% of the CDN bundle-budget gate. Load it lazily behind Suspense, mirroring FormWrapper's existing lazy-load of the same component. Deposit exposes drop from 659.5 to 633.7 KiB gzip (budget 664.1 KiB). Co-Authored-By: Claude Fable 5 --- .../Pages/Deposit/Wallet/ProcessingStep.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/widget/core/src/components/Pages/Deposit/Wallet/ProcessingStep.tsx b/packages/widget/core/src/components/Pages/Deposit/Wallet/ProcessingStep.tsx index ae4caadeff..5695ba91d8 100644 --- a/packages/widget/core/src/components/Pages/Deposit/Wallet/ProcessingStep.tsx +++ b/packages/widget/core/src/components/Pages/Deposit/Wallet/ProcessingStep.tsx @@ -1,9 +1,14 @@ -import { FC } from "react"; -import SwapDetails from "@/components/Pages/Swap/Withdraw/SwapDetails"; +import { FC, Suspense, lazy } from "react"; +import { SwapDetailsSceleton } from "@/components/Common/Sceletons"; import { Partner } from "@/Models/Partner"; import { useDepositStep, useReportCloseLock } from "../depositStepContext"; import { useResolvedSwapStatus } from "@/hooks/useResolvedSwapStatus"; +// SwapDetails transitively imports the whole Swap-processing subtree; loading +// it lazily (as FormWrapper already does) keeps it out of the Deposit exposes' +// sync bundle, which is nearly at the CDN bundle-budget gate. +const SwapDetails = lazy(() => import("@/components/Pages/Swap/Withdraw/SwapDetails")); + type Props = { partner?: Partner; }; @@ -26,11 +31,13 @@ const ProcessingStep: FC = ({ partner }) => { return (

- + }> + +
); }; From 2fffaec98399b4bcd173fa4991a7faa142055f14 Mon Sep 17 00:00:00 2001 From: babkenmes Date: Wed, 5 Aug 2026 19:28:40 +0400 Subject: [PATCH 20/24] chore(widget): block npm publish while the loader points at the sandbox CDN WIDGET_MANIFEST_URL is deliberately hardcoded, which means the only defense against publishing @layerswap/widget-js/-react with the sandbox storage account baked in was memory. Add a prepublishOnly gate to both packages that hard-fails while the loader source still references layerswapcdntest. Co-Authored-By: Claude Fable 5 --- packages/widget/js/package.json | 1 + .../js/scripts/assert-production-origin.mjs | 27 +++++++++++++++++++ packages/widget/react/package.json | 1 + 3 files changed, 29 insertions(+) create mode 100644 packages/widget/js/scripts/assert-production-origin.mjs diff --git a/packages/widget/js/package.json b/packages/widget/js/package.json index ecf87a4a65..6b515ddd2f 100644 --- a/packages/widget/js/package.json +++ b/packages/widget/js/package.json @@ -24,6 +24,7 @@ "build": "pnpm clean && pnpm build:esm+types", "build:esm+types": "tsc --project tsconfig.json --rootDir ./src --outDir ./dist/esm --declaration --declarationMap --declarationDir ./dist/types && tsc-alias -p tsconfig.json --outDir ./dist/esm && tsc-alias -p tsconfig.json --outDir ./dist/types", "check:types": "tsc --noEmit", + "prepublishOnly": "node scripts/assert-production-origin.mjs", "test": "pnpm build && node --test tests/*.test.mjs", "clean": "rimraf dist tsconfig.tsbuildinfo", "dev": "tsc -p tsconfig.json --rootDir ./src --outDir ./dist/esm --declaration --declarationMap --declarationDir ./dist/types --watch" diff --git a/packages/widget/js/scripts/assert-production-origin.mjs b/packages/widget/js/scripts/assert-production-origin.mjs new file mode 100644 index 0000000000..8154bc3410 --- /dev/null +++ b/packages/widget/js/scripts/assert-production-origin.mjs @@ -0,0 +1,27 @@ +// prepublishOnly gate shared by @layerswap/widget-js and @layerswap/widget-react +// (react ships the loader via its widget-js dependency, so both publishes are +// poisoned by a sandbox origin). +// +// WIDGET_MANIFEST_URL is deliberately hardcoded — integrators must not be able +// to repoint the widget's trust anchor. The flip side is that publishing to npm +// with the sandbox storage account still baked in would pin every real +// integrator to a personal Azure account. This check makes that publish +// impossible instead of merely documented. +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const SANDBOX_MARKER = 'layerswapcdntest'; +const manifestPath = fileURLToPath(new URL('../src/manifest.ts', import.meta.url)); +const source = readFileSync(manifestPath, 'utf8'); + +if (source.includes(SANDBOX_MARKER)) { + console.error( + `[assert-production-origin] refusing to publish: packages/widget/js/src/manifest.ts still ` + + `references the sandbox storage account ("${SANDBOX_MARKER}").\n` + + `Point WIDGET_MANIFEST_URL at the production CDN origin (and bake in the matching ` + + `production verify key) before publishing @layerswap/widget-js or @layerswap/widget-react.`, + ); + process.exit(1); +} + +console.log('[assert-production-origin] OK — no sandbox origin in the loader source'); diff --git a/packages/widget/react/package.json b/packages/widget/react/package.json index 58a3e55a23..5cdde7119a 100644 --- a/packages/widget/react/package.json +++ b/packages/widget/react/package.json @@ -25,6 +25,7 @@ "build:esm+types": "tsc --project tsconfig.json --rootDir ./src --outDir ./dist/esm --declaration --declarationMap --declarationDir ./dist/types && tsc-alias -p tsconfig.json --outDir ./dist/esm && tsc-alias -p tsconfig.json --outDir ./dist/types", "check:types": "tsc --noEmit && tsc --project tsconfig.type-tests.json --noEmit", "clean": "rimraf dist tsconfig.tsbuildinfo", + "prepublishOnly": "node ../js/scripts/assert-production-origin.mjs", "dev": "tsc -p tsconfig.json --rootDir ./src --outDir ./dist/esm --declaration --declarationMap --declarationDir ./dist/types --watch", "test": "pnpm build && node --test tests/*.test.mjs" }, From f858897e0e69b800188ec94215d9bb5f8ce52000 Mon Sep 17 00:00:00 2001 From: babkenmes Date: Wed, 5 Aug 2026 19:28:49 +0400 Subject: [PATCH 21/24] fix(wallet-evm): invalidate in-flight RPC probes by generation, not context identity Comparing connector/chain ids can't tell two in-flight probes for the same chain apart: on a fast A->B->A flip-flop, the original A-probe resolving late would overwrite the fresh verdict. Replace the identity check with a monotonic generation counter, bumped on every connector/chain/connectedness change and whenever a newer probe starts, so only the latest probe's verdict lands. Co-Authored-By: Claude Fable 5 --- .../wallets/evm/src/rpcHealthCheckProvider.ts | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/wallets/evm/src/rpcHealthCheckProvider.ts b/packages/wallets/evm/src/rpcHealthCheckProvider.ts index 651f1b9324..b42d091555 100644 --- a/packages/wallets/evm/src/rpcHealthCheckProvider.ts +++ b/packages/wallets/evm/src/rpcHealthCheckProvider.ts @@ -78,16 +78,22 @@ function createStore(): RpcHealthCheckStore { return { connector, isConnected, chainId: state.wagmiAccount.chainId } } + // Monotonic probe generation. Comparing connector/chain ids can't tell two + // in-flight probes for the *same* chain apart (a fast A→B→A flip-flop), so + // an older probe resolving late could overwrite a fresher verdict. Every + // context change and every newly started probe bumps the generation; a + // probe's verdict counts only while its generation is still the latest. + let probeGeneration = 0 + const check = async () => { - const { connector, isConnected, chainId } = getActiveConnector() + const { connector, isConnected } = getActiveConnector() if (!connector || !isConnected) return - // The probe is async — the wallet can switch chain (or connector) while it's - // in flight, and a verdict for the old chain must not be stamped onto the new one. - const isCurrent = () => { - const now = getActiveConnector() - return now.connector?.id === connector.id && now.chainId === chainId - } + // Claim a generation synchronously, before any await — any context change + // (chain switch, disconnect) or a fresher probe starting invalidates this + // one, even if this one happens to resolve later. + const myGeneration = ++probeGeneration + const isCurrent = () => probeGeneration === myGeneration try { const provider = (await connector.getProvider()) as Eip1193Provider | null @@ -186,13 +192,16 @@ function createStore(): RpcHealthCheckStore { lastConnectorId = connectorId lastIsConnected = isConnected lastChainId = chainId - // The previous verdict belongs to the old connector/chain — drop back to - // "unknown" (banner hidden) until the fresh probe for this chain resolves. + // The previous verdict belongs to the old connector/chain — invalidate + // any probe still in flight and drop back to "unknown" (banner hidden) + // until the fresh probe for this chain resolves. + probeGeneration++ setSnapshot({ health: { status: undefined } satisfies RpcHealth }) if (connector && isConnected) void check() }) const { connector, isConnected, chainId } = getActiveConnector() if (connector?.id !== lastConnectorId || chainId !== lastChainId) { + probeGeneration++ setSnapshot({ health: { status: undefined } satisfies RpcHealth }) } lastConnectorId = connector?.id From a5f0c20a1c993cbe20ad5da81430927fb3012238 Mon Sep 17 00:00:00 2001 From: babkenmes Date: Wed, 5 Aug 2026 19:28:58 +0400 Subject: [PATCH 22/24] docs(widget-example): hoist widget props to stable references in the host example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline object/function literals defeated useWalletProviders' reference- equality memo, rebuilding wallet providers on every host re-render — and the example is the pattern integrators copy. Hoist config, walletProvidersConfig, destination, callbacks, and handlers to typed module constants with a comment explaining the stability requirement. Co-Authored-By: Claude Fable 5 --- examples/widget-react-host/src/App.tsx | 60 +++++++++++++++++--------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/examples/widget-react-host/src/App.tsx b/examples/widget-react-host/src/App.tsx index d0780784c4..48df9e339e 100644 --- a/examples/widget-react-host/src/App.tsx +++ b/examples/widget-react-host/src/App.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { WagmiProvider } from 'wagmi'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { LayerswapWidget, LayerswapDepositWidget } from '@layerswap/widget-react'; +import type { LayerswapWidgetProps, LayerswapDepositWidgetProps } from '@layerswap/widget-react'; import { wagmiConfig } from './wagmi'; import { HostWallet } from './HostWallet'; @@ -11,6 +12,34 @@ const queryClient = new QueryClient(); // deposit address here — the deposit widget never asks the end user for it. const DEPOSIT_DEMO_ADDRESS = '0x2fc617e933a52713247ce25730f6695920b3befe'; +// Keep object/function props referentially stable — the widget memoizes its +// wallet-provider setup on these by identity, so fresh inline literals on +// every host render would needlessly rebuild wallet providers. Hoist them +// (or useMemo/useCallback them if they depend on host state). +const WIDGET_CONFIG: LayerswapWidgetProps['config'] = { version: 'mainnet' }; +const WALLET_PROVIDERS_CONFIG: LayerswapWidgetProps['walletProvidersConfig'] = { + exclude: ['tron', 'fuel'], +}; +const DEPOSIT_DESTINATION: LayerswapDepositWidgetProps['destination'] = { + network: 'BASE_MAINNET', + tokens: ['USDC', 'ETH'], +}; +const SWAP_CALLBACKS: LayerswapWidgetProps['callbacks'] = { + onSwapCreate: (swap) => console.log('[host] swap created', swap), + onSwapComplete: (swap) => console.log('[host] swap complete', swap), + onError: (err) => console.warn('[host] widget error', err), + onSwapModalStateChange: (open) => console.log('[host] swap modal', open), +}; +const DEPOSIT_CALLBACKS: LayerswapDepositWidgetProps['callbacks'] = { + onSwapCreate: (swap) => console.log('[host] deposit swap created', swap), + onSwapComplete: (swap) => console.log('[host] deposit complete', swap), + onError: (err) => console.warn('[host] deposit widget error', err), +}; +const onWidgetReady = () => console.log('[embed] widget ready'); +const onWidgetLoadError = (err: unknown) => console.error('[embed] failed to load', err); +const onDepositWidgetReady = () => console.log('[embed] deposit widget ready'); +const onDepositWidgetLoadError = (err: unknown) => console.error('[embed] failed to load', err); + type WidgetTab = 'swap' | 'deposit'; const loadingFallback = ( @@ -72,34 +101,25 @@ export function App() {
{tab === 'swap' ? ( console.log('[host] swap created', swap), - onSwapComplete: (swap) => console.log('[host] swap complete', swap), - onError: (err) => console.warn('[host] widget error', err), - onSwapModalStateChange: (open) => console.log('[host] swap modal', open), - }} + callbacks={SWAP_CALLBACKS} fallback={loadingFallback} - onReady={() => console.log('[embed] widget ready')} - onError={(err) => console.error('[embed] failed to load', err)} + onReady={onWidgetReady} + onError={onWidgetLoadError} /> ) : ( console.log('[host] deposit swap created', swap), - onSwapComplete: (swap) => console.log('[host] deposit complete', swap), - onError: (err) => console.warn('[host] deposit widget error', err), - }} + callbacks={DEPOSIT_CALLBACKS} fallback={loadingFallback} - onReady={() => console.log('[embed] deposit widget ready')} - onError={(err) => console.error('[embed] failed to load', err)} + onReady={onDepositWidgetReady} + onError={onDepositWidgetLoadError} /> )}
From 5aaf6339cbd26e76c279f6aa9e9db95298cd03a0 Mon Sep 17 00:00:00 2001 From: babkenmes Date: Wed, 26 Aug 2026 16:42:02 +0400 Subject: [PATCH 23/24] ci(widget): secure CDN publishing workflow --- .changeset/secure-widget-cdn-release.md | 6 + .github/CODEOWNERS | 8 + .github/workflows/rebase-main-sandbox.yml | 14 +- .github/workflows/widget-cdn-cleanup.yml | 37 +++-- .github/workflows/widget-cdn-deploy.yml | 143 +++++++++++++----- .github/workflows/widget-cdn-pr.yml | 57 +++++++ apps/widget-cdn/.keys/manifest-public.b64.txt | 2 +- apps/widget-cdn/AZURE_SETUP.md | 51 +++++-- apps/widget-cdn/README.md | 31 +++- packages/widget/js/src/manifest.ts | 5 +- 10 files changed, 278 insertions(+), 76 deletions(-) create mode 100644 .changeset/secure-widget-cdn-release.md create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/widget-cdn-pr.yml diff --git a/.changeset/secure-widget-cdn-release.md b/.changeset/secure-widget-cdn-release.md new file mode 100644 index 0000000000..3c8030dfe1 --- /dev/null +++ b/.changeset/secure-widget-cdn-release.md @@ -0,0 +1,6 @@ +--- +'@layerswap/widget-js': patch +'@layerswap/widget-react': patch +--- + +Rotate the widget manifest trust anchor and prepare the public loaders for the protected CDN release workflow. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..0b4996b74f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,8 @@ +# Frontend release security boundary. Changes to these files require review +# from a maintainer who can approve widget publishing and deployment changes. +/.github/CODEOWNERS @layerswap/frontend-release-maintainers +/.github/workflows/ @layerswap/frontend-release-maintainers +/.github/scripts/ @layerswap/frontend-release-maintainers +/apps/widget-cdn/scripts/ @layerswap/frontend-release-maintainers +/packages/widget/js/src/manifest.ts @layerswap/frontend-release-maintainers +/pnpm-lock.yaml @layerswap/frontend-release-maintainers diff --git a/.github/workflows/rebase-main-sandbox.yml b/.github/workflows/rebase-main-sandbox.yml index f2d3646f52..9bc6e80817 100644 --- a/.github/workflows/rebase-main-sandbox.yml +++ b/.github/workflows/rebase-main-sandbox.yml @@ -3,20 +3,24 @@ on: push: branches: [main] permissions: - contents: write + contents: read jobs: rebase-main-sandbox: + permissions: + contents: write timeout-minutes: 2 runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + fetch-depth: 0 - name: Set Git config run: | git config --local user.email "actions@github.com" git config --local user.name "Github Actions" - name: Merge main to main-sandbox run: | - git fetch --unshallow - git checkout main-sandbox + git fetch origin main-sandbox + git checkout -B main-sandbox origin/main-sandbox git rebase main - git push + git push origin HEAD:main-sandbox diff --git a/.github/workflows/widget-cdn-cleanup.yml b/.github/workflows/widget-cdn-cleanup.yml index da1c8d513f..3ac6bf9b3f 100644 --- a/.github/workflows/widget-cdn-cleanup.yml +++ b/.github/workflows/widget-cdn-cleanup.yml @@ -21,9 +21,7 @@ name: widget-cdn — cleanup old builds # jobs declare the environment — if it has required reviewers, the read-only # plan needs one approval and the delete a second, informed one. # -# Note: `schedule` only fires from the repository's default branch; until this -# file lands there, run it via workflow_dispatch (pick the branch — the Azure -# sandbox OIDC credential is scoped to codex/widget-cdn-azure-sandbox). +# Note: `schedule` only fires from the repository's default branch (`dev`). on: schedule: @@ -52,7 +50,6 @@ concurrency: permissions: contents: read - id-token: write env: DEPLOY_TARGET: ${{ inputs.environment || 'widget-cdn-azure-sandbox' }} @@ -62,12 +59,18 @@ env: jobs: plan: runs-on: ubuntu-latest + timeout-minutes: 20 environment: ${{ inputs.environment || 'widget-cdn-azure-sandbox' }} + permissions: + contents: read + id-token: write outputs: has-deletions: ${{ steps.plan.outputs.has-deletions }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false - name: Resolve storage target env: @@ -79,9 +82,9 @@ jobs: RAW_CDN_BASE_URL: ${{ vars.CDN_BASE_URL }} run: bash .github/scripts/resolve-widget-cdn-env.sh - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: 20 cache: pnpm @@ -93,7 +96,7 @@ jobs: run: pnpm --filter @layerswap/widget-types build - name: Log in to Azure with OIDC - uses: azure/login@v2 + uses: azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2 with: client-id: ${{ env.AZURE_CLIENT_ID }} tenant-id: ${{ env.AZURE_TENANT_ID }} @@ -124,7 +127,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Upload plan artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: cleanup-plan path: ${{ runner.temp }}/plan.json @@ -134,12 +137,18 @@ jobs: needs: plan if: ${{ needs.plan.outputs.has-deletions == 'true' }} runs-on: ubuntu-latest + timeout-minutes: 20 # The approval gate: this environment's required reviewers must approve # before anything is deleted from the container. environment: ${{ inputs.environment || 'widget-cdn-azure-sandbox' }} + permissions: + contents: read + id-token: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false - name: Resolve storage target env: @@ -151,9 +160,9 @@ jobs: RAW_CDN_BASE_URL: ${{ vars.CDN_BASE_URL }} run: bash .github/scripts/resolve-widget-cdn-env.sh - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: 20 cache: pnpm @@ -165,13 +174,13 @@ jobs: run: pnpm --filter @layerswap/widget-types build - name: Download approved plan - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: cleanup-plan path: ${{ runner.temp }} - name: Log in to Azure with OIDC - uses: azure/login@v2 + uses: azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2 with: client-id: ${{ env.AZURE_CLIENT_ID }} tenant-id: ${{ env.AZURE_TENANT_ID }} diff --git a/.github/workflows/widget-cdn-deploy.yml b/.github/workflows/widget-cdn-deploy.yml index 6ad5da3b4e..fc0084f496 100644 --- a/.github/workflows/widget-cdn-deploy.yml +++ b/.github/workflows/widget-cdn-deploy.yml @@ -16,20 +16,19 @@ name: widget-cdn — deploy # the environment has any variables configured. # - widget-cdn-production: drop-in — fails with a clear error until a repo # admin creates the production Azure identity/storage account and fills in -# the environment. Dispatches only from the default branch. +# the environment. Dispatches only from main, after dev is merged to main. # -# A push to dev that touches the widget build graph deploys to the sandbox. -# The sandbox environment has required reviewers, so every push deploy pauses -# for approval ("Review deployments") before touching storage — nothing ships -# unreviewed. Production is workflow_dispatch with -# environment=widget-cdn-production and its own required reviewers. +# A push to dev that touches the widget build graph prepares an unsigned release +# candidate for the sandbox. The job that signs it, obtains Azure credentials, +# and writes to storage targets the protected GitHub environment and therefore +# pauses for required-reviewer approval ("Review deployments"). Production is +# workflow_dispatch with environment=widget-cdn-production and its own required +# reviewers. on: push: branches: - dev - # Pre-merge sandbox deploys — remove once this PR lands on dev. - - codex/widget-cdn-azure-sandbox paths: - 'apps/widget-cdn/**' - 'packages/**' @@ -56,44 +55,36 @@ concurrency: permissions: contents: read - id-token: write jobs: - deploy: + build: runs-on: ubuntu-latest - # Required reviewers on this environment turn every deploy into an - # approvable step; the cleanup workflow's apply job shares the same gate. - environment: ${{ inputs.environment || 'widget-cdn-azure-sandbox' }} + timeout-minutes: 30 env: DEPLOY_TARGET: ${{ inputs.environment || 'widget-cdn-azure-sandbox' }} - LAYERSWAP_PROMOTE: ${{ github.event_name != 'workflow_dispatch' || inputs.promote != false }} + outputs: + build-id: ${{ steps.metadata.outputs.build-id }} + build-time: ${{ steps.metadata.outputs.build-time }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: fetch-depth: 0 + persist-credentials: false - - name: Resolve deployment target - env: - RAW_AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} - RAW_AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} - RAW_AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} - RAW_AZURE_STORAGE_ACCOUNT: ${{ vars.AZURE_STORAGE_ACCOUNT }} - RAW_AZURE_STORAGE_CONTAINER: ${{ vars.AZURE_STORAGE_CONTAINER }} - RAW_CDN_BASE_URL: ${{ vars.CDN_BASE_URL }} + - name: Validate deployment target run: | set -euo pipefail - # Production must ship code that has landed on the default branch. + # Production must ship code that has completed the dev -> main path. if [ "$DEPLOY_TARGET" = "widget-cdn-production" ] \ - && [ "$GITHUB_REF_NAME" != "${{ github.event.repository.default_branch }}" ]; then - echo "::error::Refusing to deploy '$GITHUB_REF_NAME' to production — dispatch from '${{ github.event.repository.default_branch }}'." + && [ "$GITHUB_REF_NAME" != "main" ]; then + echo "::error::Refusing to deploy '$GITHUB_REF_NAME' to production — dispatch from 'main'." exit 1 fi - bash .github/scripts/resolve-widget-cdn-env.sh - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: 20 cache: pnpm @@ -111,12 +102,96 @@ jobs: pnpm --filter @layerswap/widget-react check:types - name: Compute build metadata + id: metadata run: | - echo "LAYERSWAP_GIT_SHA=${GITHUB_SHA}" >> "$GITHUB_ENV" - echo "LAYERSWAP_BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV" + BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) BUILD_ID=$(node --input-type=module -e "import { resolveBuildIdentity } from './apps/widget-cdn/scripts/build-id.mjs'; process.stdout.write(resolveBuildIdentity('./apps/widget-cdn').buildId)") + echo "build-time=${BUILD_TIME}" >> "$GITHUB_OUTPUT" + echo "build-id=${BUILD_ID}" >> "$GITHUB_OUTPUT" + echo "LAYERSWAP_GIT_SHA=${GITHUB_SHA}" >> "$GITHUB_ENV" + echo "LAYERSWAP_BUILD_TIME=${BUILD_TIME}" >> "$GITHUB_ENV" echo "LAYERSWAP_BUILD_ID=${BUILD_ID}" >> "$GITHUB_ENV" + # Build before approval so reviewers gate a concrete release candidate. + # It remains unsigned: the signing key is only exposed to the protected + # deploy job after approval. + - name: Build unsigned release candidate + working-directory: apps/widget-cdn + run: pnpm build + + - name: Upload release candidate + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: widget-cdn-${{ steps.metadata.outputs.build-id }} + path: apps/widget-cdn/dist + if-no-files-found: error + retention-days: 7 + + - name: Summary + run: | + { + echo "## widget-cdn release candidate" + echo "" + echo "- Target: \`${DEPLOY_TARGET}\`" + echo "- Build id: \`${LAYERSWAP_BUILD_ID}\`" + echo "- Commit: \`${GITHUB_SHA}\`" + echo "" + echo "Build and tests passed. Approve the protected deployment environment to publish this candidate." + } >> "$GITHUB_STEP_SUMMARY" + + deploy: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 20 + # This is the approval boundary. Configure required reviewers for both + # environments in Settings → Environments; no signing or Azure access is + # possible before this job is approved. + environment: ${{ inputs.environment || 'widget-cdn-azure-sandbox' }} + permissions: + contents: read + id-token: write + env: + DEPLOY_TARGET: ${{ inputs.environment || 'widget-cdn-azure-sandbox' }} + LAYERSWAP_PROMOTE: ${{ github.event_name != 'workflow_dispatch' || inputs.promote != false }} + LAYERSWAP_GIT_SHA: ${{ github.sha }} + LAYERSWAP_BUILD_TIME: ${{ needs.build.outputs.build-time }} + LAYERSWAP_BUILD_ID: ${{ needs.build.outputs.build-id }} + + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Resolve deployment target + env: + RAW_AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + RAW_AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + RAW_AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} + RAW_AZURE_STORAGE_ACCOUNT: ${{ vars.AZURE_STORAGE_ACCOUNT }} + RAW_AZURE_STORAGE_CONTAINER: ${{ vars.AZURE_STORAGE_CONTAINER }} + RAW_CDN_BASE_URL: ${{ vars.CDN_BASE_URL }} + run: bash .github/scripts/resolve-widget-cdn-env.sh + + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build shared widget protocol + run: pnpm --filter @layerswap/widget-types build + + - name: Download approved release candidate + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: widget-cdn-${{ needs.build.outputs.build-id }} + path: apps/widget-cdn/dist + # The signing key is the target environment's own secret. Rotating a key # during a normal deploy would break every existing installation pinned # to its public half. @@ -134,16 +209,16 @@ jobs: printf '%s' "$PRIVATE_KEY_PEM" > "$KEY_PATH" echo "key-path=$KEY_PATH" >> "$GITHUB_OUTPUT" - - name: Build signed artifact + - name: Sign and verify approved artifact working-directory: apps/widget-cdn env: LAYERSWAP_PRIVATE_KEY_PEM: ${{ steps.key.outputs.key-path }} run: | - pnpm build + node scripts/build-manifest.mjs pnpm verify-manifest - name: Log in to Azure with OIDC - uses: azure/login@v2 + uses: azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2 with: client-id: ${{ env.AZURE_CLIENT_ID }} tenant-id: ${{ env.AZURE_TENANT_ID }} diff --git a/.github/workflows/widget-cdn-pr.yml b/.github/workflows/widget-cdn-pr.yml new file mode 100644 index 0000000000..943ebdcbcc --- /dev/null +++ b/.github/workflows/widget-cdn-pr.yml @@ -0,0 +1,57 @@ +name: widget-cdn — PR validation + +on: + pull_request: + branches: [dev, main] + paths: + - "apps/widget-cdn/**" + - "examples/widget-react-host/**" + - "packages/**" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - ".github/workflows/widget-cdn-*.yml" + - ".github/scripts/resolve-widget-cdn-env.sh" + +# PR code is untrusted. This workflow uses a GitHub-hosted runner, has only a +# read-only token, and never references deployment environments or secrets. +permissions: + contents: read + +concurrency: + group: widget-cdn-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build workspace packages + run: pnpm build:packages + + - name: Test widget packages and release tooling + run: | + pnpm --filter @layerswap/widget-cdn test + pnpm --filter @layerswap/widget-js test + pnpm --filter @layerswap/widget-react check:types + pnpm --filter @layerswap/widget-react test + + - name: Build release candidates + run: | + pnpm --filter @layerswap/widget-cdn build + pnpm --filter widget-react-host-example build diff --git a/apps/widget-cdn/.keys/manifest-public.b64.txt b/apps/widget-cdn/.keys/manifest-public.b64.txt index e49d84107b..efd00e8aea 100644 --- a/apps/widget-cdn/.keys/manifest-public.b64.txt +++ b/apps/widget-cdn/.keys/manifest-public.b64.txt @@ -1 +1 @@ -MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESuHFHbltz/hfcY+DzIrLq7Ixc4efHE8SLZdNg0pZZDHTfdwbqLpGk4461EgNranHLWnVsoAbyQ4IyHIVnRAVKw== +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY9/zg6ZUU8ZVOAbuS4qqreIR6/U8BDqY+8giwi8xFYl5dllwzE0x//l2iVRgo3fr6nRpplm9RUYRKcXaiJC/Lg== diff --git a/apps/widget-cdn/AZURE_SETUP.md b/apps/widget-cdn/AZURE_SETUP.md index 231e5a4c24..07bfcaef99 100644 --- a/apps/widget-cdn/AZURE_SETUP.md +++ b/apps/widget-cdn/AZURE_SETUP.md @@ -180,13 +180,14 @@ manifest URL and signature verification policy are fixed inside whose internal `WIDGET_MANIFEST_URL` and trust anchor target the sandbox; do not add host environment variables or globals to bypass that boundary. -## 10. GitHub Actions branch sandbox +## 10. GitHub Actions environments After local delivery works, `.github/workflows/widget-cdn-deploy.yml` deploys -this feature branch to the personal account through OIDC (its default target -is the `widget-cdn-azure-sandbox` GitHub environment; the sandbox identifiers -are inline fallbacks, so only the `LAYERSWAP_PRIVATE_KEY_PEM` signing secret -is required). +merges to `dev` to the personal account through OIDC. Its automatic target is +the `widget-cdn-azure-sandbox` GitHub environment; the sandbox identifiers are +inline fallbacks, so only the `LAYERSWAP_PRIVATE_KEY_PEM` signing secret is +required. Production is a manual dispatch from `main` to the +`widget-cdn-production` environment. In Azure Portal: @@ -197,28 +198,48 @@ In Azure Portal: 4. Select the GitHub Actions scenario and configure: - Organization: `layerswap` - Repository: `layerswapapp` - - Entity type: **Branch** - - Branch: `codex/widget-cdn-azure-sandbox` + - Entity type: **Environment** + - Environment: `widget-cdn-azure-sandbox` 5. Assign this application **Storage Blob Data Contributor** on only the `widget-cdn` container. - -The workflow is triggered by pushes to that branch. Azure accepts an OIDC -token only when its subject is exactly: +6. Create a separate production identity and environment-based federated + credential for `widget-cdn-production`, scoped only to the production + container. + +In GitHub, a repository administrator must then: + +1. Open **Settings → Environments** and create + `widget-cdn-azure-sandbox`. +2. Add one or more **Required reviewers** under deployment protection rules. + Enable **Prevent self-review** when the person who starts a deployment must + not approve it. +3. Add `LAYERSWAP_PRIVATE_KEY_PEM` as an environment secret and add the Azure + identifiers described in the [CI deploy configuration](./README.md#ci-deploy) + as environment variables. +4. Repeat for `widget-cdn-production`, using the production approvers, + identity, storage account, and signing key. + +Do not place the signing key or Azure deployment configuration at repository +scope. Environment-scoped values remain unavailable to the deploy job until a +required reviewer approves it. + +Because each privileged job declares a GitHub Environment, Azure accepts its +OIDC token only when the corresponding subject is exactly: ```text -repo:layerswap/layerswapapp:ref:refs/heads/codex/widget-cdn-azure-sandbox +repo:layerswap/layerswapapp:environment:widget-cdn-azure-sandbox +repo:layerswap/layerswapapp:environment:widget-cdn-production ``` The Azure client, tenant, subscription, storage account, and container -identifiers in the sandbox workflow are not credentials. The workflow -generates a disposable signing key for each run. Production should instead -use a protected GitHub environment and the production signing key or KMS. +identifiers in the sandbox workflow are not credentials. The workflow builds +and tests an unsigned candidate first; only the approved environment job can +access its signing key or request the Azure OIDC token. ## Production follow-up The Layerswap account should replace this public sandbox origin with Azure Front Door and preferably a private Blob origin. The final rollout also needs: -- GitHub Actions OIDC instead of a developer's Azure CLI session. - The production manifest signing key/KMS. - A stable custom domain such as `cdn.layerswap.io`. diff --git a/apps/widget-cdn/README.md b/apps/widget-cdn/README.md index f12c450cd8..0901bf43be 100644 --- a/apps/widget-cdn/README.md +++ b/apps/widget-cdn/README.md @@ -125,9 +125,11 @@ new commit gets a fresh control-file prefix. ### CI deploy -Deploys go through `.github/workflows/widget-cdn-deploy.yml`: build, test, -sign (key never leaves CI), verify, upload, promote, then smoke-test the -public channel. The deploy target is a **GitHub environment**: +Deploys go through `.github/workflows/widget-cdn-deploy.yml` as two jobs. The +`build` job tests the release and uploads an unsigned release candidate. The +`deploy` job waits for approval of the target **GitHub environment**, then +signs that candidate (the key never leaves CI), verifies it, uploads it, +promotes the channel, and smoke-tests the public endpoint: - **`widget-cdn-azure-sandbox`** — the personal-account sandbox. A push deploys here; its Azure identifiers are hardcoded fallbacks in @@ -143,8 +145,23 @@ Each environment carries its Azure identity as environment **variables** `AZURE_STORAGE_ACCOUNT`, optional `AZURE_STORAGE_CONTAINER` and `CDN_BASE_URL` for a custom domain) and its **own** signing keypair as the environment **secret** `LAYERSWAP_PRIVATE_KEY_PEM` — the production key must -never exist at repo level. Add required reviewers to an environment and every -deploy (and cleanup apply) to it pauses on "Review deployments". +never exist at repo level. + +Approval protection is required configuration; workflow YAML can reference an +environment but cannot define its reviewers. A repo admin must configure both +deployment environments in **Settings → Environments**: + +1. Open `widget-cdn-azure-sandbox`, add one or more **Required reviewers**, and + enable **Prevent self-review** if the deploy initiator must not approve it. +2. Repeat for `widget-cdn-production` with the production approver group. +3. Keep the Azure variables and `LAYERSWAP_PRIVATE_KEY_PEM` scoped to the + environment, not the repository. This ensures they are unavailable until + the approval is granted. + +With those protection rules enabled, every CDN deploy pauses at the `deploy` +job with "Review deployments". The job cannot sign a manifest, request an +Azure OIDC token, or write to Blob Storage before approval. Cleanup `apply` +jobs use the same gate. ### Blob cache and retention policy @@ -208,6 +225,10 @@ in Settings → Environments → `widget-cdn-azure-sandbox`. | variable | `AZURE_STORAGE_CONTAINER` | Optional; default `widget-cdn`. | | variable | `CDN_BASE_URL` | Optional; custom domain / Front Door origin for smoke tests. Defaults to the Blob endpoint. | +Each environment must also have a **Required reviewers** deployment protection +rule. This rule is what enforces approval; creating an unprotected environment +with the same name is not sufficient. + Azure authentication uses OIDC federation (`azure/login`) — no storage keys or connection strings are stored as secrets. diff --git a/packages/widget/js/src/manifest.ts b/packages/widget/js/src/manifest.ts index 20fc9de0ca..380bda4844 100644 --- a/packages/widget/js/src/manifest.ts +++ b/packages/widget/js/src/manifest.ts @@ -92,11 +92,12 @@ export type Manifest = { * Rotating this key requires a version bump of `@layerswap/widget-js`; * integrators pin it transitively via npm SRI. * - * Current key: generated 2026-06 (pre-KMS). A future KMS/HSM migration must + * Current sandbox key: rotated 2026-08 (pre-KMS). A future production + * KMS/HSM migration must * update this constant plus the GitHub secret `LAYERSWAP_PRIVATE_KEY_PEM`. */ export const MANIFEST_VERIFY_PUBLIC_KEY_SPKI_B64 = - 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESuHFHbltz/hfcY+DzIrLq7Ixc4efHE8SLZdNg0pZZDHTfdwbqLpGk4461EgNranHLWnVsoAbyQ4IyHIVnRAVKw=='; + 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY9/zg6ZUU8ZVOAbuS4qqreIR6/U8BDqY+8giwi8xFYl5dllwzE0x//l2iVRgo3fr6nRpplm9RUYRKcXaiJC/Lg=='; /** * Canonical Layerswap CDN manifest URL — the fixed source the loaders always From 6959b952bb05f5e8f7440114ddef2d7c08ed609a Mon Sep 17 00:00:00 2001 From: babkenmes Date: Wed, 26 Aug 2026 20:10:06 +0400 Subject: [PATCH 24/24] fix(widget-types): emit .js extensions in ESM output for Node compatibility The widget-cdn release-tooling tests run the built widget-types package directly under node --test, where extensionless relative imports fail to resolve. Enable tsc-alias resolveFullPaths (same pattern as wallets/core) so the build appends .js extensions to relative imports in dist. Co-Authored-By: Claude Fable 5 --- packages/widget/types/tsconfig.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/widget/types/tsconfig.json b/packages/widget/types/tsconfig.json index 96c4f7f763..d6fd15eb4f 100644 --- a/packages/widget/types/tsconfig.json +++ b/packages/widget/types/tsconfig.json @@ -13,6 +13,9 @@ "skipLibCheck": true, "esModuleInterop": true }, + "tsc-alias": { + "resolveFullPaths": true + }, "include": ["src"], "exclude": ["node_modules", "dist"] }