diff --git a/apps/web/package.json b/apps/web/package.json index c34e504..c27df76 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -73,6 +73,7 @@ "@turbostarter/eslint-config": "workspace:*", "@turbostarter/prettier-config": "workspace:*", "@turbostarter/tsconfig": "workspace:*", + "@types/libsodium-wrappers": "0.7.14", "@types/node": "catalog:node22", "@types/qrcode": "1.5.6", "@types/react": "catalog:react19", diff --git a/apps/web/src/modules/mesh/topic-chat-panel.tsx b/apps/web/src/modules/mesh/topic-chat-panel.tsx index ba9b3b6..0b7b792 100644 --- a/apps/web/src/modules/mesh/topic-chat-panel.tsx +++ b/apps/web/src/modules/mesh/topic-chat-panel.tsx @@ -5,12 +5,22 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@turbostarter/ui-web/button"; +import { + decryptMessage, + encryptMessage, + getTopicKey, + registerBrowserPeerPubkey, +} from "~/services/crypto/topic-key"; + interface TopicMessage { id: string; senderPubkey: string; senderName: string; nonce: string; ciphertext: string; + /** 1 = legacy plaintext-base64. 2 = crypto_secretbox under topic key. */ + bodyVersion?: number; + replyToId?: string | null; createdAt: string; } @@ -35,12 +45,28 @@ interface Props { } /** - * Encode plaintext into the broker's wire format. v0.2.0 uses base64 - * plaintext in the `ciphertext` field β€” real per-topic symmetric keys - * land in v0.3.0. Same applies to the random nonce: it satisfies the - * schema but isn't cryptographically meaningful yet. + * v1 (legacy plaintext-base64) decode path. v0.2.0 messages used this + * fake-encryption stub; real v0.3.0 ciphertext is decrypted via the + * topic key β€” see `decryptForRender` below. */ -function encodeOutgoing(plaintext: string): { ciphertext: string; nonce: string } { +function decodeV1(ciphertext: string): string { + try { + const decoded = + typeof window === "undefined" + ? Buffer.from(ciphertext, "base64").toString("utf-8") + : new TextDecoder().decode( + Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0)), + ); + return decoded; + } catch { + return "[decode failed]"; + } +} + +/** Encode v1 plaintext for the rare fallback path when a topic has no + * encryption key (legacy v0.2.0 topics). v0.3.0+ topics encrypt via + * `encryptMessage` from the topic-key service. */ +function encodeV1Outgoing(plaintext: string): { ciphertext: string; nonce: string } { const bytes = new TextEncoder().encode(plaintext); const ciphertext = typeof window === "undefined" @@ -55,20 +81,6 @@ function encodeOutgoing(plaintext: string): { ciphertext: string; nonce: string return { ciphertext, nonce }; } -function decodeIncoming(ciphertext: string): string { - try { - const decoded = - typeof window === "undefined" - ? Buffer.from(ciphertext, "base64").toString("utf-8") - : new TextDecoder().decode( - Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0)), - ); - return decoded; - } catch { - return "[decode failed]"; - } -} - /** * Render plaintext with @mentions highlighted in clay. We split on the * mention regex and rebuild as alternating spans so React can reconcile @@ -187,6 +199,19 @@ export function TopicChatPanel({ const seenIdsRef = useRef>(new Set()); const lastMarkReadAtRef = useRef(0); + // v0.3.0 per-topic encryption state. + // `topicKey` is the 32-byte symmetric key for the active topic (null = + // unencrypted / not yet sealed for this browser). `keyState` distinguishes + // the three reasons we might not have a key yet, so the UI can show the + // right message ("waiting for a CLI peer to share the key" vs "topic is + // legacy plaintext" vs "decrypt failed"). + const [topicKey, setTopicKey] = useState(null); + const [keyState, setKeyState] = useState< + "loading" | "ready" | "not_sealed" | "topic_unencrypted" | "error" + >("loading"); + // Decrypted plaintext per message id, computed lazily on render. + const [decrypted, setDecrypted] = useState>(new Map()); + const headers = useMemo( () => ({ Authorization: `Bearer ${apiKeySecret}`, @@ -238,6 +263,95 @@ export function TopicChatPanel({ void markRead(); }, [loadHistory, markRead]); + // Per-topic encryption bootstrap. + // + // On mount: register the browser's IndexedDB-persisted pubkey against + // mesh.member.peer_pubkey (idempotent), then ask /v1/topics/:name/key + // for our sealed copy. If no peer has sealed for us yet (404), poll + // every 5s β€” the CLI's 30s re-seal loop will eventually catch up. + // If the topic is unencrypted (legacy v0.2.0), fall through to v1. + useEffect(() => { + let cancelled = false; + let pollTimer: ReturnType | null = null; + + const tryFetchKey = async (firstAttempt: boolean) => { + try { + if (firstAttempt) { + // Idempotent β€” only writes on first run / after rotation. + await registerBrowserPeerPubkey(apiKeySecret); + } + const res = await getTopicKey({ apiKeySecret, topicName }); + if (cancelled) return; + if (res.ok && res.topicKey) { + setTopicKey(res.topicKey); + setKeyState("ready"); + return; + } + if (res.error === "topic_unencrypted") { + setTopicKey(null); + setKeyState("topic_unencrypted"); + return; + } + if (res.error === "not_sealed") { + setTopicKey(null); + setKeyState("not_sealed"); + // Re-poll: a CLI peer's re-seal loop runs every 30s, so 5s + // here gives a quick reaction without hammering the server. + pollTimer = setTimeout(() => void tryFetchKey(false), 5000); + return; + } + setKeyState("error"); + } catch { + if (!cancelled) setKeyState("error"); + } + }; + void tryFetchKey(true); + + return () => { + cancelled = true; + if (pollTimer) clearTimeout(pollTimer); + }; + }, [apiKeySecret, topicName]); + + // Decrypt any v2 messages that we haven't decrypted yet. Runs after + // `messages` updates (history backfill, SSE delivery) and after + // `topicKey` lands. + useEffect(() => { + if (!topicKey) return; + let cancelled = false; + (async () => { + const additions = new Map(); + for (const m of messages) { + if ((m.bodyVersion ?? 1) !== 2) continue; + if (decrypted.has(m.id)) continue; + const plain = await decryptMessage(topicKey, m.ciphertext, m.nonce); + additions.set(m.id, plain ?? "[decrypt failed]"); + } + if (cancelled || additions.size === 0) return; + setDecrypted((prev) => { + const next = new Map(prev); + for (const [k, v] of additions) next.set(k, v); + return next; + }); + })(); + return () => { + cancelled = true; + }; + }, [messages, topicKey, decrypted]); + + // Render-time text resolution: v2 -> decrypted cache; v1 -> legacy decode. + // Falls back to a placeholder if v2 hasn't been decrypted yet (the + // useEffect above will fill it in). + const resolveText = useCallback( + (m: TopicMessage): string => { + if ((m.bodyVersion ?? 1) === 2) { + return decrypted.get(m.id) ?? "πŸ”’ decrypting…"; + } + return decodeV1(m.ciphertext); + }, + [decrypted], + ); + // Roster β€” refresh every 20s so online state stays roughly current. // Tighter cadence isn't worth a dedicated SSE channel for v1.6.x. useEffect(() => { @@ -435,7 +549,24 @@ export function TopicChatPanel({ setSending(true); setError(null); try { - const { ciphertext, nonce } = encodeOutgoing(text); + let ciphertext: string; + let nonce: string; + let bodyVersion: 1 | 2; + if (topicKey && keyState === "ready") { + const enc = await encryptMessage(topicKey, text); + ciphertext = enc.ciphertext; + nonce = enc.nonce; + bodyVersion = 2; + } else { + // Legacy unencrypted topic, or sealed-key not yet available. + // Sending v1 plaintext keeps the chat working in either case; + // CLI peers on encrypted topics will read it as v1 (alongside + // their v2 traffic) without the round-trip breaking. + const enc = encodeV1Outgoing(text); + ciphertext = enc.ciphertext; + nonce = enc.nonce; + bodyVersion = 1; + } const mentions = extractMentions(text); const res = await fetch("/api/v1/messages", { method: "POST", @@ -444,6 +575,7 @@ export function TopicChatPanel({ topic: topicName, ciphertext, nonce, + bodyVersion, ...(mentions.length > 0 ? { mentions } : {}), }), }); @@ -491,10 +623,10 @@ export function TopicChatPanel({ const filteredMessages = useMemo(() => { if (!searchTerm) return messages; return messages.filter((m) => - decodeIncoming(m.ciphertext).toLowerCase().includes(searchTerm) || + resolveText(m).toLowerCase().includes(searchTerm) || (m.senderName ?? "").toLowerCase().includes(searchTerm), ); - }, [messages, searchTerm]); + }, [messages, searchTerm, resolveText]); return (
@@ -588,7 +720,15 @@ export function TopicChatPanel({

- {renderWithMentions(decodeIncoming(m.ciphertext))} + {(m.bodyVersion ?? 1) === 2 ? ( + + πŸ”’ + + ) : null} + {renderWithMentions(resolveText(m))}

))} @@ -693,6 +833,23 @@ export function TopicChatPanel({ error Β· {error}

) : null} + {keyState === "not_sealed" ? ( +

+ πŸ”’ waiting for a CLI peer to share the topic key β€” sending v1 plaintext until then +

+ ) : keyState === "ready" ? ( +

+ πŸ”’ end-to-end encrypted (v0.3.0) +

+ ) : null}
{ diff --git a/apps/web/src/services/crypto/identity.ts b/apps/web/src/services/crypto/identity.ts new file mode 100644 index 0000000..b49a6b6 --- /dev/null +++ b/apps/web/src/services/crypto/identity.ts @@ -0,0 +1,136 @@ +/** + * Browser-side persistent peer identity for claudemesh. + * + * Stores an ed25519 keypair in IndexedDB so the same browser tab, + * the same browser after a reload, and the same user across reloads + * keeps the same identity. Without this, every page-reload would + * mint a new pubkey and the broker's per-topic-key seal would have + * to chase a moving target. + * + * The keypair lives at `claudemesh-identity / kp / default`. There's + * one identity per browser profile, shared across every mesh the + * dashboard user is in. The matching `mesh.member.peer_pubkey` rows + * are kept in sync server-side via `POST /v1/me/peer-pubkey`. + * + * Threat model: IndexedDB is per-origin and not exfiltratable from + * other sites. A malicious extension or full XSS still wins β€” same + * as for any browser-stored secret. The CLI's own keypair has + * stronger guarantees because it lives in `~/.claudemesh/` outside + * of the browser. We document the divergence in the dashboard UI. + */ + +import sodium from "libsodium-wrappers"; + +export interface BrowserIdentity { + /** ed25519 public key β€” registered as `mesh.member.peer_pubkey`. */ + edPub: Uint8Array; + /** ed25519 secret key β€” never leaves IndexedDB. */ + edSec: Uint8Array; + /** x25519 public key, derived from edPub. Used in `crypto_box`. */ + xPub: Uint8Array; + /** x25519 secret key, derived from edSec. Used in `crypto_box_open`. */ + xSec: Uint8Array; + /** Hex form of `edPub` β€” what the API and DB store. */ + edPubHex: string; +} + +const DB_NAME = "claudemesh-identity"; +const STORE = "kp"; +const KEY = "default"; + +let cached: BrowserIdentity | null = null; +let initPromise: Promise | null = null; + +async function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => { + req.result.createObjectStore(STORE); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function readKeypair(): Promise<{ + edPub: Uint8Array; + edSec: Uint8Array; +} | null> { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readonly"); + const req = tx.objectStore(STORE).get(KEY); + req.onsuccess = () => { + const v = req.result as + | { edPub: Uint8Array; edSec: Uint8Array } + | undefined; + resolve(v ?? null); + }; + req.onerror = () => reject(req.error); + }); +} + +async function writeKeypair(kp: { + edPub: Uint8Array; + edSec: Uint8Array; +}): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).put(kp, KEY); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +/** + * Get-or-create the browser's persistent identity. First call on a + * given origin generates a fresh ed25519 keypair, persists it, and + * derives the matching x25519 pair. Subsequent calls return the + * in-memory cache. + * + * Server registration (`POST /v1/me/peer-pubkey`) is the caller's + * responsibility β€” this module only manages the local keypair. + */ +export async function getBrowserIdentity(): Promise { + if (cached) return cached; + if (initPromise) return initPromise; + + initPromise = (async () => { + await sodium.ready; + let stored = await readKeypair(); + if (!stored) { + const kp = sodium.crypto_sign_keypair(); + stored = { edPub: kp.publicKey, edSec: kp.privateKey }; + await writeKeypair(stored); + } + const xPub = sodium.crypto_sign_ed25519_pk_to_curve25519(stored.edPub); + const xSec = sodium.crypto_sign_ed25519_sk_to_curve25519(stored.edSec); + cached = { + edPub: stored.edPub, + edSec: stored.edSec, + xPub, + xSec, + edPubHex: sodium.to_hex(stored.edPub), + }; + return cached; + })(); + return initPromise; +} + +/** + * Wipe the local identity. The server-side `mesh.member.peer_pubkey` + * is NOT cleared by this β€” call `POST /v1/me/peer-pubkey` again with + * a fresh pubkey after rotation. + */ +export async function clearBrowserIdentity(): Promise { + cached = null; + initPromise = null; + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).delete(KEY); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} diff --git a/apps/web/src/services/crypto/topic-key.ts b/apps/web/src/services/crypto/topic-key.ts new file mode 100644 index 0000000..023596a --- /dev/null +++ b/apps/web/src/services/crypto/topic-key.ts @@ -0,0 +1,220 @@ +/** + * Browser port of the CLI's per-topic key crypto. + * + * Mirrors apps/cli/src/services/crypto/topic-key.ts so a single mental + * model covers both surfaces: + * + * 1. UI mints a REST apikey for the dashboard user. + * 2. UI ensures `mesh.member.peer_pubkey` matches the browser's + * IndexedDB-persisted identity via POST /v1/me/peer-pubkey. + * 3. UI fetches GET /v1/topics/:name/key. Once any CLI peer has + * re-sealed the topic key for this member, the response carries + * `<32-byte sender x25519 pubkey> || crypto_box(topicKey)`. + * 4. UI converts the browser's ed25519 secret to x25519 and + * crypto_box_open's the seal. + * 5. Plaintext topic key is cached in-memory (per apikey + topic) + * and used for crypto_secretbox encrypt + decrypt of v2 message + * bodies. + * + * Cache key uses the apikey prefix so a logout clears it implicitly. + * Refresh on logout / 401 to avoid leaking keys across sessions. + */ + +import sodium from "libsodium-wrappers"; + +import { getBrowserIdentity } from "./identity"; + +interface CacheEntry { + topicKey: Uint8Array; + fetchedAt: number; +} + +const cache = new Map(); + +interface SealedKeyResponse { + topic: string; + topicId: string; + encryptedKey: string; + nonce: string; + senderPubkey: string; + createdAt: string; +} + +export type TopicKeyError = + | "not_sealed" + | "topic_unencrypted" + | "decrypt_failed" + | "bad_member_secret" + | "network"; + +export interface TopicKeyResult { + ok: boolean; + topicKey?: Uint8Array; + error?: TopicKeyError; + message?: string; +} + +function cacheKey(apiKeySecret: string, topicName: string): string { + return `${apiKeySecret.slice(0, 12)}:${topicName}`; +} + +async function fetchSealed( + apiKeySecret: string, + topicName: string, +): Promise<{ ok: true; data: SealedKeyResponse } | { ok: false; status: number; message?: string }> { + const res = await fetch(`/api/v1/topics/${encodeURIComponent(topicName)}/key`, { + headers: { Authorization: `Bearer ${apiKeySecret}` }, + }); + if (!res.ok) { + let message: string | undefined; + try { + const body = (await res.json()) as { error?: string }; + message = body.error; + } catch { + // empty + } + return { ok: false, status: res.status, message }; + } + const data = (await res.json()) as SealedKeyResponse; + return { ok: true, data }; +} + +export async function getTopicKey(args: { + apiKeySecret: string; + topicName: string; + /** Bypass cache β€” useful after a re-seal lands. */ + fresh?: boolean; +}): Promise { + const cacheId = cacheKey(args.apiKeySecret, args.topicName); + if (!args.fresh) { + const cached = cache.get(cacheId); + if (cached) return { ok: true, topicKey: cached.topicKey }; + } + + const sealed = await fetchSealed(args.apiKeySecret, args.topicName); + if (!sealed.ok) { + if (sealed.status === 404) return { ok: false, error: "not_sealed" }; + if (sealed.status === 409) + return { ok: false, error: "topic_unencrypted" }; + return { + ok: false, + error: "network", + message: sealed.message ?? `HTTP ${sealed.status}`, + }; + } + + await sodium.ready; + const identity = await getBrowserIdentity(); + + let topicKey: Uint8Array; + try { + const blob = sodium.from_base64( + sealed.data.encryptedKey, + sodium.base64_variants.ORIGINAL, + ); + const nonce = sodium.from_base64( + sealed.data.nonce, + sodium.base64_variants.ORIGINAL, + ); + if (blob.length < 32 + sodium.crypto_box_MACBYTES) { + return { + ok: false, + error: "decrypt_failed", + message: "sealed key blob too short to contain sender pubkey + cipher", + }; + } + const senderX25519 = blob.slice(0, 32); + const cipher = blob.slice(32); + topicKey = sodium.crypto_box_open_easy( + cipher, + nonce, + senderX25519, + identity.xSec, + ); + } catch (e) { + return { + ok: false, + error: "decrypt_failed", + message: e instanceof Error ? e.message : String(e), + }; + } + + cache.set(cacheId, { topicKey, fetchedAt: Date.now() }); + return { ok: true, topicKey }; +} + +/** + * Encrypt a UTF-8 plaintext with the topic key. Output matches the + * v0.3.0 wire format: bodyVersion=2, ciphertext+nonce both base64. + */ +export async function encryptMessage( + topicKey: Uint8Array, + plaintext: string, +): Promise<{ ciphertext: string; nonce: string }> { + await sodium.ready; + const nonceBytes = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const cipher = sodium.crypto_secretbox_easy( + sodium.from_string(plaintext), + nonceBytes, + topicKey, + ); + return { + ciphertext: sodium.to_base64(cipher, sodium.base64_variants.ORIGINAL), + nonce: sodium.to_base64(nonceBytes, sodium.base64_variants.ORIGINAL), + }; +} + +/** + * Decrypt a v2 ciphertext body. Returns null on auth failure so the + * caller can render a placeholder rather than crash. + */ +export async function decryptMessage( + topicKey: Uint8Array, + ciphertextB64: string, + nonceB64: string, +): Promise { + try { + await sodium.ready; + const cipher = sodium.from_base64( + ciphertextB64, + sodium.base64_variants.ORIGINAL, + ); + const nonce = sodium.from_base64(nonceB64, sodium.base64_variants.ORIGINAL); + const plain = sodium.crypto_secretbox_open_easy(cipher, nonce, topicKey); + return sodium.to_string(plain); + } catch { + return null; + } +} + +/** + * Register the browser's identity pubkey on the server so the next + * CLI re-seal pass can include this browser as a recipient. Idempotent. + * + * Returns `{ changed }` so callers can react (e.g. nudge "waiting for + * a CLI peer to share the topic key" until the next re-seal lands). + */ +export async function registerBrowserPeerPubkey( + apiKeySecret: string, +): Promise<{ memberId: string; pubkey: string; changed: boolean }> { + const identity = await getBrowserIdentity(); + const res = await fetch("/api/v1/me/peer-pubkey", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKeySecret}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ pubkey: identity.edPubHex }), + }); + if (!res.ok) { + let detail: string; + try { + const j = (await res.json()) as { error?: string }; + detail = j.error ?? `HTTP ${res.status}`; + } catch { + detail = `HTTP ${res.status}`; + } + throw new Error(`peer-pubkey registration failed: ${detail}`); + } + return (await res.json()) as { memberId: string; pubkey: string; changed: boolean }; +} diff --git a/packages/api/src/modules/mesh/v1-router.ts b/packages/api/src/modules/mesh/v1-router.ts index 78ca138..9856bac 100644 --- a/packages/api/src/modules/mesh/v1-router.ts +++ b/packages/api/src/modules/mesh/v1-router.ts @@ -271,6 +271,61 @@ export const v1Router = new Hono() }); }) + // POST /v1/me/peer-pubkey β€” register the caller's persistent peer pubkey. + // + // Browser users get a throwaway ed25519 pubkey at mesh-create time + // (no secret retained). To participate in v0.3.0 per-topic encryption + // they must replace it with a pubkey whose secret they actually hold + // (persisted in IndexedDB). This endpoint writes the new pubkey on the + // mesh.member row identified by the apikey's issuer; the broker / CLI + // re-seal loop then picks them up as a regular topic-key recipient + // within ~30s. + // + // Idempotent: same pubkey β†’ no-op; different pubkey β†’ updates and + // bumps `joined_at` so re-sealers notice the change. We do NOT + // invalidate the existing sealed topic_member_key rows here β€” + // they're keyed by member, not pubkey, and the next CLI re-seal pass + // will overwrite them with copies sealed to the new pubkey. + .post( + "/me/peer-pubkey", + validate( + "json", + z.object({ + pubkey: z + .string() + .length(64) + .regex(/^[0-9a-f]{64}$/i, "must be 64 lowercase hex chars"), + }), + ), + async (c) => { + const key = c.var.apiKey; + if (!key.issuedByMemberId) { + return c.json({ error: "api_key_has_no_issuer" }, 400); + } + const body = c.req.valid("json"); + const newPubkey = body.pubkey.toLowerCase(); + const [existing] = await db + .select({ peerPubkey: meshMember.peerPubkey }) + .from(meshMember) + .where(eq(meshMember.id, key.issuedByMemberId)); + if (!existing) { + return c.json({ error: "member_not_found" }, 404); + } + const changed = existing.peerPubkey !== newPubkey; + if (changed) { + await db + .update(meshMember) + .set({ peerPubkey: newPubkey }) + .where(eq(meshMember.id, key.issuedByMemberId)); + } + return c.json({ + memberId: key.issuedByMemberId, + pubkey: newPubkey, + changed, + }); + }, + ) + // GET /v1/topics β€” list topics in the key's mesh // Includes per-topic unread counts when the key has an issuing member // (i.e. dashboard keys; CLI-minted keys also carry it). Counts are diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ff5186..1a18f49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -453,6 +453,9 @@ importers: '@turbostarter/tsconfig': specifier: workspace:* version: link:../../tooling/typescript + '@types/libsodium-wrappers': + specifier: 0.7.14 + version: 0.7.14 '@types/node': specifier: catalog:node22 version: 22.16.0 @@ -21848,7 +21851,7 @@ snapshots: '@sentry/bundler-plugin-core': 4.6.1(encoding@0.1.13) unplugin: 1.0.1 uuid: 9.0.1 - webpack: 5.100.2(esbuild@0.25.0) + webpack: 5.100.2 transitivePeerDependencies: - encoding - supports-color @@ -30980,6 +30983,15 @@ snapshots: optionalDependencies: esbuild: 0.25.0 + terser-webpack-plugin@5.3.14(webpack@5.100.2): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.2 + serialize-javascript: 6.0.2 + terser: 5.43.1 + webpack: 5.100.2 + terser@5.43.1: dependencies: '@jridgewell/source-map': 0.3.10 @@ -31729,6 +31741,38 @@ snapshots: webpack-virtual-modules@0.5.0: {} + webpack@5.100.2: + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) + browserslist: 4.25.1 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.18.3 + es-module-lexer: 1.7.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.2 + tapable: 2.2.2 + terser-webpack-plugin: 5.3.14(webpack@5.100.2) + watchpack: 2.4.4 + webpack-sources: 3.3.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + webpack@5.100.2(esbuild@0.25.0): dependencies: '@types/eslint-scope': 3.7.7