diff --git a/README.md b/README.md index 4bb16888b..f99ab90c4 100644 --- a/README.md +++ b/README.md @@ -448,7 +448,7 @@ All backend services share a single `.env` file (root or per-package). Copy `.en | `API_AUTH_KEY` | — | API key for protected endpoints | | `CORS_ORIGINS` | `http://localhost:3000` | Comma-separated allowed origins (required in production) | | `WS_AUTH_SECRET` | — | HMAC secret for WebSocket token auth | -| `WS_AUTH_REQUIRED` | `false` | Require WS auth tokens | +| `WS_AUTH_REQUIRED` | *(prod: `true`, else `false`)* | Require WS auth tokens. Default follows `NODE_ENV`; set explicitly to override. | | `MAX_WS_CONNECTIONS` | `1000` | Global WebSocket connection limit | ### Keeper Service diff --git a/SECURITY.md b/SECURITY.md index d6f3e0523..9415efe6a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -32,7 +32,12 @@ WebSocket connections support optional authentication: ### Configuration -- `WS_AUTH_REQUIRED=true` — Require authentication (default: false) +- `WS_AUTH_REQUIRED` — Require authentication. **The default is environment-dependent, not `false`:** + required when `NODE_ENV=production`, optional otherwise. Setting the variable to + `true` or `false` overrides that in either direction. + (Implemented in `percolator-api/src/routes/ws.ts:52-56`; this repo only documents it.) + Startup fails closed: production without `WS_AUTH_SECRET` exits, and so does + `WS_AUTH_REQUIRED=true` without a secret. - `WS_AUTH_SECRET` — Secret key for HMAC tokens (change in production!) ### Authentication Methods @@ -164,7 +169,9 @@ Rate limit violations are: CORS_ORIGINS=http://localhost:3000,http://localhost:3001 ``` -2. **Keep auth disabled** for easier testing +2. **Auth is already off in development** — the default outside + `NODE_ENV=production` is optional, so no setting is needed. Set it explicitly + only to override: ```bash WS_AUTH_REQUIRED=false ``` diff --git a/app/__tests__/api/issue-2509-malformed-body.test.ts b/app/__tests__/api/issue-2509-malformed-body.test.ts new file mode 100644 index 000000000..2894617b1 --- /dev/null +++ b/app/__tests__/api/issue-2509-malformed-body.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +// #2509 — omitting `slabAddress` deliberately means "apply to ALL admin-oracle +// markets". The old handler caught a JSON parse failure and left `body = {}`, +// so a truncated or corrupt payload silently widened a single-market change +// into a fleet-wide one. Empty and malformed are different requests. +describe("#2509 set-price-cap distinguishes empty from malformed body", () => { + const src = fs.readFileSync( + path.join(__dirname, "../../app/api/oracle/set-price-cap/route.ts"), + "utf8", + ); + + it("reads the raw body and only parses when non-empty", () => { + expect(src).toMatch(/await req\.text\(\)/); + expect(src).toMatch(/rawBody\.trim\(\)\s*!==\s*""/); + }); + + it("returns 400 on malformed JSON rather than falling through", () => { + // the parse failure path must produce a 400, not an empty-object default + const parseCatch = src.slice(src.indexOf("JSON.parse(rawBody)")); + expect(parseCatch).toMatch(/status:\s*400/); + expect(parseCatch).toMatch(/Malformed JSON body/); + }); + + it("no longer treats a parse failure as the all-markets default", () => { + // the old shape: `try { body = await req.json() } catch { /* empty ok */ }` + expect(src).not.toMatch(/body\s*=\s*await req\.json\(\)/); + }); + + it("rejects a non-object JSON body (array / null) too", () => { + expect(src).toMatch(/Array\.isArray\(body\)/); + }); +}); diff --git a/app/__tests__/api/issue-2510-truncation.test.ts b/app/__tests__/api/issue-2510-truncation.test.ts new file mode 100644 index 000000000..718714a03 --- /dev/null +++ b/app/__tests__/api/issue-2510-truncation.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +// #2510 — `totalTrades: rows.length` reported the CAPPED row count with nothing +// marking it as a floor, so a wallet above the cap silently under-reported every +// aggregate (volume, fees, unique markets) with no signal to the caller. +describe("#2510 trader stats surface truncation", () => { + const src = fs.readFileSync( + path.join(__dirname, "../../app/api/trader/[wallet]/stats/route.ts"), + "utf8", + ); + + it("names the cap once instead of repeating a literal", () => { + expect(src).toMatch(/export const TRADER_STATS_MAX_ROWS = 10_000;/); + // the raw literal must not survive in the queries, or cap and flag can drift + expect(src).not.toMatch(/\.limit\(10_000\)/); + }); + + it("applies the named cap to BOTH query paths (primary and fallback)", () => { + const uses = src.match(/\.limit\(TRADER_STATS_MAX_ROWS\)/g) ?? []; + expect(uses.length).toBeGreaterThanOrEqual(2); + }); + + it("exposes a truncated flag derived from the cap", () => { + expect(src).toMatch(/truncated:\s*rows\.length >= TRADER_STATS_MAX_ROWS/); + }); + + it("declares truncated on the response type", () => { + expect(src).toMatch(/truncated:\s*boolean;/); + }); + + it("sets truncated:false on the empty-result path", () => { + expect(src).toMatch(/totalTrades:\s*0,\s*\n\s*truncated:\s*false,/); + }); +}); diff --git a/app/__tests__/api/issue-2520-mint-existence.test.ts b/app/__tests__/api/issue-2520-mint-existence.test.ts new file mode 100644 index 000000000..28199c740 --- /dev/null +++ b/app/__tests__/api/issue-2520-mint-existence.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +// #2520 — every pre-existing check on this route was SHAPE-only (valid pubkey, +// printable name, ticker charset, decimals range). A syntactically valid pubkey +// for an account that was never created still landed in `devnet_mints`. +describe("#2520 devnet-register-mint verifies the mint on-chain", () => { + const src = fs.readFileSync( + path.join(__dirname, "../../app/api/devnet-register-mint/route.ts"), + "utf8", + ); + + it("fetches the account before upserting", () => { + expect(src).toMatch(/getAccountInfo\(new PublicKey\(mintAddress\)\)/); + }); + + it("rejects a nonexistent account", () => { + expect(src).toMatch(/does not exist on devnet/); + }); + + it("requires the token program as owner AND the exact SPL mint length", () => { + expect(src).toMatch(/owner\.equals\(TOKEN_PROGRAM_ID\)/); + expect(src).toMatch(/SPL_MINT_LEN\s*=\s*82/); + expect(src).toMatch(/data\.length\s*!==\s*SPL_MINT_LEN/); + }); + + it("fails CLOSED on an RPC error rather than falling through to the upsert", () => { + // an advisory check that skips on error would leave the issue half-closed + const catchBlock = src.slice(src.indexOf("mint existence check failed")); + expect(catchBlock).toMatch(/status:\s*503/); + expect(catchBlock).toMatch(/return NextResponse\.json/); + }); + + it("runs BEFORE the DB upsert", () => { + expect(src.indexOf("getAccountInfo")).toBeLessThan(src.indexOf('from("devnet_mints")')); + }); +}); diff --git a/app/__tests__/components/FooterSocialA11y.test.tsx b/app/__tests__/components/FooterSocialA11y.test.tsx new file mode 100644 index 000000000..9f61c3d5a --- /dev/null +++ b/app/__tests__/components/FooterSocialA11y.test.tsx @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +// #2243 — the footer social icons carried `title` only. `title` is not a reliable +// accessible name (screen readers vary, and it is invisible on keyboard focus), so +// each link needs an explicit aria-label and each decorative glyph aria-hidden. +describe("#2243 footer social links have accessible names", () => { + const src = fs.readFileSync( + path.join(__dirname, "../../components/layout/Footer.tsx"), + "utf8", + ); + + const socials = ["GitHub", "X (Twitter)", "Discord", "Telegram"]; + + it.each(socials)("labels the %s link", (name) => { + expect(src).toContain(`aria-label="Percolator on ${name}"`); + }); + + it("hides every decorative social glyph from the a11y tree", () => { + // Count the social anchors by their aria-labels, then require at least as many + // aria-hidden svgs — so adding a link without hiding its glyph fails here. + const labelled = (src.match(/aria-label="Percolator on /g) ?? []).length; + const hidden = (src.match(/
= 0 ? "+" : ""}${r.toFixed(4)}%`} - className={`w-6 rounded-sm ${isPos ? "bg-green-500/60" : "bg-red-500/60"}`} + // #2368: semantic colours come from the design tokens, not Tailwind's + // palette — `bg-green-500`/`bg-red-500` are fixed sRGB and do not follow a + // theme change, so this sparkline drifted from every other long/short + // surface. + // + // Direction matches THIS FILE's own convention for the same quantity: a + // POSITIVE funding rate is rendered with --short (`:365` for + // eightHourRatePercent, `:298` for userPays), because positive funding + // means longs pay. The old green/red pair read the opposite way round, + // so the sparkline disagreed with the headline rate directly above it. + className={`w-6 rounded-sm ${isPos ? "bg-[var(--short)]/60" : "bg-[var(--long)]/60"}`} style={{ height: `${heightPct}%` }} /> ); diff --git a/app/components/trade/TradingChart.tsx b/app/components/trade/TradingChart.tsx index 15b2894fc..a53725d46 100644 --- a/app/components/trade/TradingChart.tsx +++ b/app/components/trade/TradingChart.tsx @@ -623,7 +623,13 @@ const TradingChartInner: FC<{ slabAddress: string; mintAddress?: string }> = ({ const { sparse: effectiveSparse } = hasRenderableData(chartStyle, candleData, lineData); // Phase 2: volume has data (used to show empty state in volume pane) - const hasVolumeData = candleData.some((c) => (c.volume ?? 0) > 0); + // #2321: guard on Number.isFinite, not just `> 0`. NaN > 0 is false so NaN was + // already excluded here, but Infinity > 0 is TRUE — so a single corrupt candle + // from external data enabled the volume pane and handed Infinity straight to + // the histogram series, which then scales the whole pane off that value. + const hasVolumeData = candleData.some( + (c) => Number.isFinite(c.volume) && (c.volume ?? 0) > 0, + ); // Indicator overlays (SMA / EMA / Bollinger). Memo the filtered subset so // the overlay hook's effect only re-runs when the user actually adds / diff --git a/app/hooks/useCreateMarket.ts b/app/hooks/useCreateMarket.ts index 30ab9327a..9cb5a02a6 100644 --- a/app/hooks/useCreateMarket.ts +++ b/app/hooks/useCreateMarket.ts @@ -70,9 +70,10 @@ import { // resending it — see the Step 3 block below. parsePortfolioV17, parseMarketGroupV17OI, + parseBackingBucketsV17, } from "@percolatorct/sdk"; import { PERCOLATOR_NFT_PROGRAM_ID } from "@/lib/nft-program"; -import { deriveMarketParams, MIN_LEVERAGE_X, backingSeedPerDomain, leverageFromMarginBps } from "@/lib/market-params"; +import { deriveMarketParams, MIN_LEVERAGE_X, backingSeedPerDomain, leverageFromMarginBps, findUnseededBackingDomains } from "@/lib/market-params"; // v17: SetOracleAuthority (tag 17), PushOraclePrice (tag 16), SetOraclePriceCap (tag 16), // and UpdateConfig (tag 14) do not exist in v17. All oracle + risk params are embedded // in InitMarket (extended tail). The sdk-compat stubs throw at runtime if called. @@ -2869,6 +2870,7 @@ export function useCreateMarket() { // must not strand an otherwise-successful market creation — a retry of // this step (or a later maintainer backfill) is safe because a repeat // TopUp against an already-Fresh-at-MAX bucket hits the harmless no-op arm. + let backingSeedError: unknown = null; if (isV17SlabDeposit) { try { const backingVaultToken = vaultTokenAta; @@ -2897,6 +2899,7 @@ export function useCreateMarket() { }); setState((s) => ({ ...s, txSigs: [...s.txSigs, backingSig] })); } catch (backingBucketErr) { + backingSeedError = backingBucketErr; console.warn( "[useCreateMarket] Step 3 backing-bucket seeding (deadlock prevention) failed — " + "market is otherwise live, but domains 0/1 may still be vulnerable to the freshness " + @@ -2904,6 +2907,58 @@ export function useCreateMarket() { backingBucketErr, ); } + + // GH#2514: assert the OUTCOME, not the transaction result. + // + // The catch above used to be the whole story: it warned to the console + // and fell through to "Market created!". So a sequential launch (an + // explicit retry/resume with startStep <= 3, or the pre-broadcast + // fallback from fresh batching) could report success with BOTH backing + // domains unseeded — the two allocations this same revision declares + // mandatory at 100% of LP collateral each (backingSeedPerDomain, + // lib/market-params.ts). The batched M3a path is unaffected: it bundles + // the deposit and both top-ups in one atomic transaction. + // + // This is the same treatment the insurance seed already gets twice in + // this file, for the same reason and in the same shape: reading the + // engine's own state catches never-built, reverted and partially + // applied alike — including the half-failure where one domain landed + // and the other did not, which no transaction result can distinguish. + // + // The assertion is deliberately on STATUS, not amount. The bucket + // stores a u128 BackingNum, not collateral atoms, so comparing it + // against backingSeed would be a units mismatch — exactly the class of + // bug that produced the "insurance already topped up" miscount recorded + // below. Empty(0) means never seeded; a Fresh bucket with nonzero + // fresh-unliened backing is what TopUpBackingBucket produces. + { + let unseeded: string[] | null = null; + try { + const slabInfoBacking = await connection.getAccountInfo(slabPk); + if (slabInfoBacking?.data) { + const parsed = parseBackingBucketsV17(new Uint8Array(slabInfoBacking.data)); + const missing = findUnseededBackingDomains(parsed.buckets); + if (missing.length > 0) unseeded = missing; + } + // Unreadable/unparseable slab: fall through without throwing. Unlike + // the insurance seed, an unseeded backing domain is recoverable — + // TopUpBackingBucket is replayable by the backing authority, and + // ExpireBackingBucket is permissionless — so failing the launch on a + // transient RPC error would strand a live market for no gain. + } catch { + // Same rationale as above. + } + if (unseeded) { + throw new Error( + `Backing domains were not seeded (${unseeded.join("; ")}). ` + + "The market exists and its liquidity is deposited — retry this step to seed them. " + + "Until then the market is exposed to the backing-freshness deadlock." + + (backingSeedError + ? ` Cause: ${backingSeedError instanceof Error ? backingSeedError.message : String(backingSeedError)}` + : ""), + ); + } + } } // TopUpInsurance + final crank — NOT part of the proven on-chain sequence diff --git a/app/hooks/useDeposit.ts b/app/hooks/useDeposit.ts index b6649e2dd..bcf4f4949 100644 --- a/app/hooks/useDeposit.ts +++ b/app/hooks/useDeposit.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Keypair, PublicKey, SystemProgram, TransactionInstruction } from "@solana/web3.js"; import { createAssociatedTokenAccountInstruction, @@ -97,6 +97,17 @@ export function useDeposit(slabAddress: string) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const inflightRef = useRef(false); + // #2323: the delayed refreshSlab below outlives the component if the user + // navigates within its 2s window — the callback then fires against an unmounted + // tree. Track pending timers so the unmount effect can clear them. + const pendingTimersRef = useRef[]>([]); + useEffect( + () => () => { + for (const t of pendingTimersRef.current) clearTimeout(t); + pendingTimersRef.current = []; + }, + [], + ); const deposit = useCallback( async (params: { userIdx: number; amount: bigint; accountExists?: boolean; portfolioPk?: PublicKey }) => { @@ -362,7 +373,7 @@ export function useDeposit(slabAddress: string) { // Force immediate slab re-read so balance updates without waiting for // the next poll cycle (which can be up to 30 s when WS is active). refreshSlab?.(); - setTimeout(() => refreshSlab?.(), 2000); + pendingTimersRef.current.push(setTimeout(() => refreshSlab?.(), 2000)); return sig; } catch (e) { setError(humanizeError(e instanceof Error ? e.message : String(e))); diff --git a/app/hooks/usePortfolio.ts b/app/hooks/usePortfolio.ts index f42b7db22..83d271ec9 100644 --- a/app/hooks/usePortfolio.ts +++ b/app/hooks/usePortfolio.ts @@ -252,6 +252,21 @@ export interface PortfolioPosition { export type LiquidationSeverity = "safe" | "warning" | "danger"; export function getLiquidationSeverity(distancePct: number): LiquidationSeverity { + // #2412: a non-finite distance must NOT read as "safe". Both comparisons below + // are FALSE for NaN (and for Infinity on the first two), so an unguarded NaN + // fell straight through to "safe" — a position at liquidation risk rendering + // as fine, which is the one direction this indicator must never fail in. + // + // NaN reaches here from ordinary upstream arithmetic: 0/0 when a position's + // notional is momentarily zero mid-refresh, or a subtraction against an + // undefined mark before the first oracle tick lands. + // + // Fail to "danger": an absent risk signal is not evidence of safety, and the + // cost of asymmetry is right — a spurious warning is noise, a suppressed one + // is a liquidation the user never saw coming. +Infinity (genuinely far from + // liquidation) is the only non-finite value that would prefer "safe", and it + // is not worth a special case against that downside. + if (!Number.isFinite(distancePct)) return "danger"; if (distancePct <= 10) return "danger"; if (distancePct <= 30) return "warning"; return "safe"; diff --git a/app/lib/indexer-db.ts b/app/lib/indexer-db.ts index 06fd11ae7..42dbaaf9d 100644 --- a/app/lib/indexer-db.ts +++ b/app/lib/indexer-db.ts @@ -22,6 +22,7 @@ // Loaded only in Node.js (Next.js server-side route handlers, never the browser). // The import is dynamic so bundling for the browser never fails. import postgres from "postgres"; +import { getServerNetwork } from "./supabase"; // ── connection pool ───────────────────────────────────────────────────────── @@ -141,6 +142,7 @@ export async function queryTrades( created_at, asset_index FROM trades WHERE slab_address = ${slabAddress} + AND network = ${getServerNetwork()} ORDER BY created_at DESC LIMIT ${limit} `; @@ -196,6 +198,7 @@ export async function queryTradesForCandles( SELECT price::text AS price, size::text AS size, created_at FROM trades WHERE slab_address = ${slabAddress} + AND network = ${getServerNetwork()} AND created_at >= ${fromIso}::timestamptz AND created_at <= ${toIso}::timestamptz ORDER BY created_at ASC @@ -324,6 +327,7 @@ export async function queryStatsAggregate(): Promise { 0 )::text AS volume_24h_raw FROM trades + WHERE network = ${getServerNetwork()} `; const row = rows[0]; return { @@ -343,7 +347,7 @@ export async function queryStatsAggregate(): Promise { export async function queryKnownSlabs(): Promise { const sql = getSql(); const rows = await sql>` - SELECT DISTINCT slab_address FROM trades LIMIT 100 + SELECT DISTINCT slab_address FROM trades WHERE network = ${getServerNetwork()} LIMIT 100 `; return rows.map((r) => r.slab_address); } @@ -392,6 +396,7 @@ export async function queryLeaderboard( MAX(created_at) AS last_trade_at FROM trades WHERE created_at >= NOW() - INTERVAL '24 hours' + AND network = ${getServerNetwork()} GROUP BY trader ORDER BY SUM(ABS(size::numeric) * price::numeric / 1e6) DESC LIMIT ${limit} @@ -405,6 +410,7 @@ export async function queryLeaderboard( MAX(created_at) AS last_trade_at FROM trades WHERE created_at >= NOW() - INTERVAL '7 days' + AND network = ${getServerNetwork()} GROUP BY trader ORDER BY SUM(ABS(size::numeric) * price::numeric / 1e6) DESC LIMIT ${limit} @@ -417,6 +423,7 @@ export async function queryLeaderboard( SUM(ABS(size::numeric) * price::numeric / 1e6)::text AS total_volume, MAX(created_at) AS last_trade_at FROM trades + WHERE network = ${getServerNetwork()} GROUP BY trader ORDER BY SUM(ABS(size::numeric) * price::numeric / 1e6) DESC LIMIT ${limit} @@ -462,6 +469,7 @@ export async function queryTraderStatsRows(wallet: string): Promise>` SELECT COUNT(*)::text AS cnt FROM trades WHERE trader = ${wallet} AND slab_address = ${slabFilter} + AND network = ${getServerNetwork()} ` : await sql>` SELECT COUNT(*)::text AS cnt FROM trades WHERE trader = ${wallet} + AND network = ${getServerNetwork()} `; const total = Number(countRows[0]?.cnt ?? "0"); @@ -522,6 +532,7 @@ export async function queryTraderTradesPage( created_at, asset_index FROM trades WHERE trader = ${wallet} AND slab_address = ${slabFilter} + AND network = ${getServerNetwork()} ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset} ` @@ -531,6 +542,7 @@ export async function queryTraderTradesPage( created_at, asset_index FROM trades WHERE trader = ${wallet} + AND network = ${getServerNetwork()} ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset} `; diff --git a/app/lib/market-params.ts b/app/lib/market-params.ts index caf34c51d..4a18d1843 100644 --- a/app/lib/market-params.ts +++ b/app/lib/market-params.ts @@ -258,3 +258,50 @@ export function deriveMarketParams( estimatedFreezeSecondsFor26PctMove: Math.round((2600 / maxPriceMoveBpsPerSlot) * 0.4), }; } + +/** + * GH#2514: which of the two launch backing domains are NOT seeded. + * + * `backingSeedPerDomain` above declares both domains mandatory (100% of LP + * collateral each), but the sequential launch path used to treat the + * TopUpBackingBucket transaction as best-effort: it caught the error, warned to + * the console, and reported `Market created!` anyway. So a retry/resume launch + * could finish with neither allocation present and nothing saying so. + * + * Pure so it can be tested without driving the whole wizard. Takes the buckets + * from `parseBackingBucketsV17`. + * + * The check is on STATUS + nonzero backing, deliberately NOT on amount: the + * bucket stores a u128 BackingNum, not collateral atoms, so comparing it + * against a `backingSeedPerDomain` result would be a units mismatch. (That is + * the same class of error as the "insurance already topped up" miscount in + * useCreateMarket, which compared a vault balance that also held these very + * backing seeds against an insurance target.) + * + * Empty means never seeded. Expired/Impaired are reported too: neither is a + * usable seed, and both are states the launch is supposed to have prevented. + */ +export const LAUNCH_BACKING_DOMAINS = [0, 1] as const; + +export interface BackingBucketLike { + domain: number; + status: number; + statusName: string; + freshUnlienedBackingNum: bigint; +} + +export function findUnseededBackingDomains( + buckets: readonly BackingBucketLike[], +): string[] { + const missing: string[] = []; + for (const domain of LAUNCH_BACKING_DOMAINS) { + const b = buckets.find((x) => x.domain === domain); + const side = domain === 0 ? "long" : "short"; + if (!b) { + missing.push(`domain ${domain} (${side}): absent`); + } else if (b.status !== 1 /* Fresh */ || b.freshUnlienedBackingNum === 0n) { + missing.push(`domain ${domain} (${side}): ${b.statusName}`); + } + } + return missing; +} diff --git a/app/lib/market-registration-auth.ts b/app/lib/market-registration-auth.ts index f5426f35e..d0299e022 100644 --- a/app/lib/market-registration-auth.ts +++ b/app/lib/market-registration-auth.ts @@ -114,8 +114,16 @@ function encodeCanonicalJson( return `{${entries.join(",")}}`; } + // #2523: name the offending TYPE. This throw is the last resort of a recursive + // canonicaliser, so without it the caller sees one opaque sentence for a value + // buried anywhere in the payload — "market creation failed" with nothing to act + // on. `typeof` is enough to identify the culprit (bigint, function, symbol and + // undefined-in-array are the reachable cases) and, unlike printing the value, + // cannot leak payload contents into a client-visible error or a log. throw new TypeError( - "Unsupported market registration payload value", + `Unsupported market registration payload value of type "${typeof value}" — ` + + "the payload must contain only JSON primitives, plain objects and arrays. " + + "bigint and function values are the usual causes; convert them before signing.", ); } diff --git a/app/lib/priceStore/priceStore.ts b/app/lib/priceStore/priceStore.ts index 49973fce3..2bb9be042 100644 --- a/app/lib/priceStore/priceStore.ts +++ b/app/lib/priceStore/priceStore.ts @@ -67,12 +67,19 @@ export const EMPTY_PRICE_STATE: PriceState = Object.freeze({ /* ── WS URL resolution — ported verbatim from the pre-refactor useLivePrice.ts ── * SECURITY REVIEW (mainnet, carried over): the WS price feed sends no auth - * token. If WS_AUTH_REQUIRED=false server-side (current default per - * SECURITY.md), any caller can open unauthenticated connections and - * enumerate active markets; per-IP connection limits are the only defense. - * If WS_AUTH_REQUIRED=true, this client fails to authenticate and silently - * falls back to REST seeding — decide before mainnet whether the feed is - * intentionally public or needs HMAC tokens matching SECURITY.md. */ + * token. GH#2525: this used to cite "WS_AUTH_REQUIRED=false, the current + * default per SECURITY.md", which was wrong in the direction that matters — + * SECURITY.md said `false` flatly, but the server that implements it + * (percolator-api/src/routes/ws.ts:52-56) defaults to REQUIRED whenever + * NODE_ENV=production and only optional otherwise. Docs corrected. + * + * So the live consequence is the opposite of what was written here: on a + * production API this client does NOT get an open feed, it fails to + * authenticate and silently falls back to REST seeding. Off production, the + * feed is open and per-IP connection limits are the only defense. + * + * Still to decide before mainnet: whether this client should carry an HMAC + * token so it keeps the WS path in production instead of degrading to REST. */ function getWsUrl(): string { const explicit = process.env.NEXT_PUBLIC_WS_URL; if (explicit !== undefined) return explicit; @@ -116,6 +123,30 @@ interface SlabEntry { const entries = new Map(); +/** + * #2320: `entries` was only ever written, never pruned. The WebSocket resources + * ARE released when the last listener unsubscribes (see the returned teardown + * below), so this was never a socket leak — but the last-known snapshot is + * deliberately retained so a quick remount or market-switch-back doesn't flash + * back to loading, and nothing bounded how many of those accumulated over a long + * session of browsing markets. + * + * Cap the retained snapshots and evict the oldest IDLE entry (no listeners, so + * nothing is subscribed to it). An entry with live listeners is never evicted, so + * the cap cannot disturb an open market. 64 is far above any realistic + * switch-back working set, which keeps the no-flash behaviour intact. + */ +const MAX_CACHED_ENTRIES = 64; + +function evictIdleEntriesIfNeeded(): void { + if (entries.size <= MAX_CACHED_ENTRIES) return; + // Map iteration is insertion-ordered, so this walks oldest-first. + for (const [slab, entry] of entries) { + if (entries.size <= MAX_CACHED_ENTRIES) return; + if (entry.listeners.size === 0) entries.delete(slab); + } +} + function getOrCreateEntry(slab: string): SlabEntry { let entry = entries.get(slab); if (!entry) { @@ -133,6 +164,7 @@ function getOrCreateEntry(slab: string): SlabEntry { lastLiveTickAt: 0, }; entries.set(slab, entry); + evictIdleEntriesIfNeeded(); } return entry; } diff --git a/app/middleware.ts b/app/middleware.ts index 499b98bc7..18772fe0c 100644 --- a/app/middleware.ts +++ b/app/middleware.ts @@ -50,7 +50,29 @@ function getUpstashLimiters(): { general: Ratelimit | null; rpc: Ratelimit | nul const url = process.env.UPSTASH_REDIS_REST_URL; const token = process.env.UPSTASH_REDIS_REST_TOKEN; - if (!url || !token) return { general: null, rpc: null }; + if (!url || !token) { + // GH#2341: unconfigured Upstash silently degrades to the per-instance + // in-memory limiter below, which on Vercel is not really a rate limit — + // every cold start gets its own budget, so an attacker spreading requests + // across instances bypasses it entirely. + // + // The init-FAILURE branch already logged in production; MISSING env vars + // returned quietly, which is the more likely way to end up here (a fresh + // deployment, or a preview promoted to prod without the vars set). So the + // case that actually happens was the one that said nothing. + // + // Logged rather than thrown: middleware runs on every request, and failing + // closed here would take the whole site down over a rate limiter. That + // trade is deliberate, and is exactly why it has to be loud. + if (process.env.NODE_ENV === "production") { + console.error( + "[RateLimit] ERROR: UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN are unset in " + + "production — falling back to the PER-INSTANCE in-memory limiter. Distributed rate " + + "limiting is NOT in effect and limits can be bypassed across serverless instances.", + ); + } + return { general: null, rpc: null }; + } try { const redis = new Redis({ url, token }); diff --git a/app/package.json b/app/package.json index bed5fc6c2..910d5d96e 100644 --- a/app/package.json +++ b/app/package.json @@ -34,7 +34,7 @@ "buffer": "^6.0.3", "gsap": "^3.14.2", "lightweight-charts": "^5.2.0", - "next": "16.2.9", + "next": "16.2.11", "postgres": "^3.4.9", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b4b55040..ecb51142a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,7 +91,7 @@ importers: version: 3.14.1(6ba7a9d9e0172521f33b8d058dae8da3) '@sentry/nextjs': specifier: 10.39.0 - version: 10.39.0(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.105.2(esbuild@0.27.3)) + version: 10.39.0(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(next@16.2.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.105.2(esbuild@0.27.3)) '@solana-program/memo': specifier: ^0.11.0 version: 0.11.0(@solana/kit@6.1.0(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)) @@ -127,7 +127,7 @@ importers: version: 1.36.4 '@vercel/analytics': specifier: ^2.0.1 - version: 2.0.1(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 2.0.1(next@16.2.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@vercel/blob': specifier: ^2.5.0 version: 2.6.1 @@ -144,8 +144,8 @@ importers: specifier: ^5.2.0 version: 5.2.0 next: - specifier: 16.2.9 - version: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 16.2.11 + version: 16.2.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) postgres: specifier: ^3.4.9 version: 3.4.9 @@ -1232,60 +1232,60 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@next/env@16.2.9': - resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==} + '@next/env@16.2.11': + resolution: {integrity: sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA==} '@next/eslint-plugin-next@16.2.9': resolution: {integrity: sha512-UZi8+YT/MLgTC9nrrn2Xd4lBYv1B7lVmtWHfPcthAI5Tt/C1LuDe6DfmtCtJ+WQod3ksY4VrKSvk3oMVAnL7qw==} - '@next/swc-darwin-arm64@16.2.9': - resolution: {integrity: sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==} + '@next/swc-darwin-arm64@16.2.11': + resolution: {integrity: sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.9': - resolution: {integrity: sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==} + '@next/swc-darwin-x64@16.2.11': + resolution: {integrity: sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.9': - resolution: {integrity: sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==} + '@next/swc-linux-arm64-gnu@16.2.11': + resolution: {integrity: sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.9': - resolution: {integrity: sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==} + '@next/swc-linux-arm64-musl@16.2.11': + resolution: {integrity: sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.9': - resolution: {integrity: sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==} + '@next/swc-linux-x64-gnu@16.2.11': + resolution: {integrity: sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.9': - resolution: {integrity: sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==} + '@next/swc-linux-x64-musl@16.2.11': + resolution: {integrity: sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.9': - resolution: {integrity: sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==} + '@next/swc-win32-arm64-msvc@16.2.11': + resolution: {integrity: sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.9': - resolution: {integrity: sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==} + '@next/swc-win32-x64-msvc@16.2.11': + resolution: {integrity: sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -5119,11 +5119,6 @@ packages: resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} engines: {node: '>=6.0.0'} - baseline-browser-mapping@2.10.20: - resolution: {integrity: sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==} - engines: {node: '>=6.0.0'} - hasBin: true - baseline-browser-mapping@2.10.44: resolution: {integrity: sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==} engines: {node: '>=6.0.0'} @@ -5293,9 +5288,6 @@ packages: caniuse-lite@1.0.30001769: resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} - caniuse-lite@1.0.30001788: - resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==} - caniuse-lite@1.0.30001806: resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} @@ -7319,8 +7311,8 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - next@16.2.9: - resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==} + next@16.2.11: + resolution: {integrity: sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -10167,7 +10159,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.8.1 + '@emnapi/runtime': 1.11.1 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -10564,34 +10556,34 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@next/env@16.2.9': {} + '@next/env@16.2.11': {} '@next/eslint-plugin-next@16.2.9': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.2.9': + '@next/swc-darwin-arm64@16.2.11': optional: true - '@next/swc-darwin-x64@16.2.9': + '@next/swc-darwin-x64@16.2.11': optional: true - '@next/swc-linux-arm64-gnu@16.2.9': + '@next/swc-linux-arm64-gnu@16.2.11': optional: true - '@next/swc-linux-arm64-musl@16.2.9': + '@next/swc-linux-arm64-musl@16.2.11': optional: true - '@next/swc-linux-x64-gnu@16.2.9': + '@next/swc-linux-x64-gnu@16.2.11': optional: true - '@next/swc-linux-x64-musl@16.2.9': + '@next/swc-linux-x64-musl@16.2.11': optional: true - '@next/swc-win32-arm64-msvc@16.2.9': + '@next/swc-win32-arm64-msvc@16.2.11': optional: true - '@next/swc-win32-x64-msvc@16.2.9': + '@next/swc-win32-x64-msvc@16.2.11': optional: true '@ngraveio/bc-ur@1.1.13': @@ -12339,7 +12331,7 @@ snapshots: '@sentry/core@10.39.0': {} - '@sentry/nextjs@10.39.0(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.105.2(esbuild@0.27.3))': + '@sentry/nextjs@10.39.0(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(next@16.2.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.105.2(esbuild@0.27.3))': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.39.0 @@ -12352,7 +12344,7 @@ snapshots: '@sentry/react': 10.39.0(react@18.3.1) '@sentry/vercel-edge': 10.39.0 '@sentry/webpack-plugin': 4.9.1(webpack@5.105.2(esbuild@0.27.3)) - next: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 16.2.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rollup: 4.59.0 stacktrace-parser: 0.1.11 transitivePeerDependencies: @@ -15316,9 +15308,9 @@ snapshots: dependencies: uncrypto: 0.1.3 - '@vercel/analytics@2.0.1(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@vercel/analytics@2.0.1(next@16.2.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': optionalDependencies: - next: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 16.2.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 '@vercel/blob@2.6.1': @@ -17302,8 +17294,6 @@ snapshots: base64url@3.0.1: {} - baseline-browser-mapping@2.10.20: {} - baseline-browser-mapping@2.10.44: {} baseline-browser-mapping@2.9.19: {} @@ -17496,8 +17486,6 @@ snapshots: caniuse-lite@1.0.30001769: {} - caniuse-lite@1.0.30001788: {} - caniuse-lite@1.0.30001806: {} canonicalize@2.1.0: {} @@ -19858,25 +19846,25 @@ snapshots: neo-async@2.6.2: {} - next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@16.2.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@next/env': 16.2.9 + '@next/env': 16.2.11 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.20 - caniuse-lite: 1.0.30001788 + baseline-browser-mapping: 2.10.44 + caniuse-lite: 1.0.30001806 postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) styled-jsx: 5.1.6(@babel/core@7.29.0)(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.9 - '@next/swc-darwin-x64': 16.2.9 - '@next/swc-linux-arm64-gnu': 16.2.9 - '@next/swc-linux-arm64-musl': 16.2.9 - '@next/swc-linux-x64-gnu': 16.2.9 - '@next/swc-linux-x64-musl': 16.2.9 - '@next/swc-win32-arm64-msvc': 16.2.9 - '@next/swc-win32-x64-msvc': 16.2.9 + '@next/swc-darwin-arm64': 16.2.11 + '@next/swc-darwin-x64': 16.2.11 + '@next/swc-linux-arm64-gnu': 16.2.11 + '@next/swc-linux-arm64-musl': 16.2.11 + '@next/swc-linux-x64-gnu': 16.2.11 + '@next/swc-linux-x64-musl': 16.2.11 + '@next/swc-win32-arm64-msvc': 16.2.11 + '@next/swc-win32-x64-msvc': 16.2.11 '@opentelemetry/api': 1.9.0 '@playwright/test': 1.58.2 sharp: 0.34.5 @@ -20343,7 +20331,7 @@ snapshots: postcss@8.4.31: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -20955,7 +20943,7 @@ snapshots: dependencies: '@img/colour': 1.0.0 detect-libc: 2.1.2 - semver: 7.7.4 + semver: 7.8.5 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5