From 16ce2319c2b66c83db1506fbe87681d173133f10 Mon Sep 17 00:00:00 2001 From: Jay Mantri Date: Thu, 30 Apr 2026 14:36:02 -0700 Subject: [PATCH 01/56] DES-21: Pagination Base UI idiom upgrades + optional totalItems (#26918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Brings Origin's `Pagination` compound component up to parity with the Base UI idioms used by the rest of Origin (matching the style of DES-18 #26842 and DES-19 #26829), and softens the API so callers without a known total are no longer blocked. [DES-21](https://lightspark.atlassian.net/browse/DES-21) (parent epic: [DES-20](https://lightspark.atlassian.net/browse/DES-20)). ## Changes - **`render` prop on every part.** Each part now goes through `useRender`, gaining a `render` prop so consumers can swap the rendered element. The motivating case is rendering `Pagination.Previous` / `Pagination.Next` as `` for shareable per-page URLs and middle-click-to-new-tab. - **`data-*` state attributes.** Component state surfaces via `useRender`'s `state` + `stateAttributesMapping`: - Root: `data-page`, `data-first-page`, `data-last-page` - Prev/Next: `data-disabled` mirrors the resolved disabled state (so anchor renders pick up the disabled visual treatment uniformly with ` - ); + return useRender({ + defaultTagName: "button", + render, + ref: forwardedRef, + state: { disabled: isDisabled }, + props: [ + { + type: "button", + "aria-label": "Previous page", + "aria-disabled": isDisabled || undefined, + disabled: isDisabled, + onClick: handleClick, + }, + elementProps, + { + className: clsx(styles.button, className), + children: children ?? ( + + ), + }, + ] as unknown as Record, + }); }); -// Next button export interface PaginationNextProps - extends Omit, "children"> {} + extends Omit, "children"> { + render?: useRender.RenderProp; + children?: React.ReactNode; +} const PaginationNext = React.forwardRef( function PaginationNext(props, forwardedRef) { - const { className, onClick, disabled, ...elementProps } = props; + const { className, onClick, disabled, render, children, ...elementProps } = + props; const { page, totalPages, onPageChange } = usePaginationContext(); - const isDisabled = disabled ?? page >= totalPages; + const isDisabled = + disabled ?? (totalPages !== undefined && page >= totalPages); - const handleClick = (event: React.MouseEvent) => { - onClick?.(event); + const handleClick = (event: React.MouseEvent) => { + if (isDisabled) { + event.preventDefault(); + return; + } + onClick?.(event as React.MouseEvent); if (!event.defaultPrevented && onPageChange) { onPageChange(page + 1); } }; - return ( - - ); + return useRender({ + defaultTagName: "button", + render, + ref: forwardedRef, + state: { disabled: isDisabled }, + props: [ + { + type: "button", + "aria-label": "Next page", + "aria-disabled": isDisabled || undefined, + disabled: isDisabled, + onClick: handleClick, + }, + elementProps, + { + className: clsx(styles.button, className), + children: children ?? ( + + ), + }, + ] as unknown as Record, + }); }, ); -// Export compound component export const Pagination = { Root: PaginationRoot, Label: PaginationLabel, @@ -278,6 +395,7 @@ export const Pagination = { Navigation: PaginationNavigation, Previous: PaginationPrevious, Next: PaginationNext, + usePaginationContext, }; export default Pagination; diff --git a/packages/origin/src/components/Pagination/Pagination.unit.test.tsx b/packages/origin/src/components/Pagination/Pagination.unit.test.tsx new file mode 100644 index 000000000..cdb8c9470 --- /dev/null +++ b/packages/origin/src/components/Pagination/Pagination.unit.test.tsx @@ -0,0 +1,241 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import * as React from "react"; +import { Pagination, usePaginationContext } from "./Pagination"; + +describe("Pagination.Root", () => { + it("exposes data-page and data-first-page on first page", () => { + render( + + + , + ); + + const nav = screen.getByRole("navigation", { name: /pagination/i }); + expect(nav).toHaveAttribute("data-page", "1"); + expect(nav).toHaveAttribute("data-first-page", ""); + expect(nav).not.toHaveAttribute("data-last-page"); + }); + + it("exposes data-last-page on the last page", () => { + render( + + + , + ); + + const nav = screen.getByRole("navigation", { name: /pagination/i }); + expect(nav).toHaveAttribute("data-page", "20"); + expect(nav).toHaveAttribute("data-last-page", ""); + expect(nav).not.toHaveAttribute("data-first-page"); + }); + + it("omits data-last-page when totalItems is not provided", () => { + render( + + + + + + , + ); + + const nav = screen.getByRole("navigation", { name: /pagination/i }); + expect(nav).not.toHaveAttribute("data-last-page"); + expect(nav).not.toHaveAttribute("data-first-page"); + }); + + it("renders as a custom element via render prop", () => { + render( + } + > + + , + ); + + const root = screen.getByTestId("root"); + expect(root.tagName).toBe("SECTION"); + expect(root).toHaveAttribute("data-page", "1"); + }); +}); + +describe("Pagination.Range", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("renders the default range string when totalItems is provided", () => { + render( + + + , + ); + + expect(screen.getByTestId("range")).toHaveTextContent("1–100 of 2.5K"); + }); + + it("warns and renders nothing when totalItems is missing without children", () => { + render( + + + , + ); + + expect(screen.queryByTestId("range")).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Pagination.Range"), + ); + }); + + it("passes undefined fields to the children render fn when totalItems is missing", () => { + const childrenFn = vi.fn(() => "custom"); + + render( + + {childrenFn} + , + ); + + expect(childrenFn).toHaveBeenCalledWith({ + startItem: undefined, + endItem: undefined, + totalItems: undefined, + }); + }); +}); + +describe("Pagination navigation buttons", () => { + it("Previous auto-disables on first page regardless of totals", () => { + render( + + + + + + , + ); + + expect(screen.getByRole("button", { name: /previous/i })).toBeDisabled(); + }); + + it("Next does not auto-disable when totalItems is omitted", () => { + render( + + + + + + , + ); + + expect(screen.getByRole("button", { name: /next/i })).toBeEnabled(); + }); + + it("Next auto-disables at the last page when totalItems is known", () => { + render( + + + + + + , + ); + + expect(screen.getByRole("button", { name: /next/i })).toBeDisabled(); + }); + + it("dispatches onPageChange with the next page on Next click", () => { + const onPageChange = vi.fn(); + render( + + + + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /next/i })); + expect(onPageChange).toHaveBeenCalledWith(3); + }); + + it("renders Previous as an anchor when render is supplied", () => { + render( + + + } + /> + } /> + + , + ); + + const prev = screen.getByTestId("prev"); + const next = screen.getByTestId("next"); + expect(prev.tagName).toBe("A"); + expect(prev).toHaveAttribute("href", "?page=2"); + expect(next.tagName).toBe("A"); + expect(next).toHaveAttribute("href", "?page=4"); + }); + + it("flags disabled anchor renders with aria-disabled and data-disabled", () => { + render( + + + } + /> + + , + ); + + const prev = screen.getByTestId("prev"); + expect(prev).toHaveAttribute("aria-disabled", "true"); + expect(prev).toHaveAttribute("data-disabled", ""); + }); +}); + +describe("usePaginationContext", () => { + function Probe() { + const ctx = usePaginationContext(); + return {ctx.page}; + } + + it("exposes context values to consumer parts", () => { + render( + + + , + ); + + expect(screen.getByTestId("probe")).toHaveTextContent("4"); + }); + + it("throws when called outside Pagination.Root", () => { + const errorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + + expect(() => render()).toThrow( + /Pagination parts must be placed within/, + ); + + errorSpy.mockRestore(); + }); +}); diff --git a/packages/origin/src/components/Pagination/index.ts b/packages/origin/src/components/Pagination/index.ts index f8c83788b..8d29660fc 100644 --- a/packages/origin/src/components/Pagination/index.ts +++ b/packages/origin/src/components/Pagination/index.ts @@ -1,5 +1,6 @@ -export { Pagination } from "./Pagination"; +export { Pagination, usePaginationContext } from "./Pagination"; export type { + PaginationContextValue, PaginationRootProps, PaginationLabelProps, PaginationRangeProps, diff --git a/packages/origin/src/index.ts b/packages/origin/src/index.ts index c9db340c5..fe91d4bf4 100644 --- a/packages/origin/src/index.ts +++ b/packages/origin/src/index.ts @@ -77,7 +77,16 @@ export { Menu } from "./components/Menu"; export { Menubar } from "./components/Menubar"; export { Meter } from "./components/Meter"; export { NavigationMenu } from "./components/NavigationMenu"; -export { Pagination } from "./components/Pagination"; +export { Pagination, usePaginationContext } from "./components/Pagination"; +export type { + PaginationContextValue, + PaginationRootProps, + PaginationLabelProps, + PaginationRangeProps, + PaginationNavigationProps, + PaginationPreviousProps, + PaginationNextProps, +} from "./components/Pagination"; export { PhoneInput } from "./components/PhoneInput"; export { Progress } from "./components/Progress"; export { Radio } from "./components/Radio"; diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index a83f3b9f6..51b1cfe8d 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,15 +1,5 @@ # @lightsparkdev/ui -## 1.1.20 - -### Patch Changes - -- d1d0682: - Add Base, Ethereum, Polygon, and Solana chain icon components, plus a `ChainIcon` helper. - - Improve package build output for CSS and SVG assets. -- Updated dependencies [d1d0682] -- Updated dependencies [d1d0682] - - @lightsparkdev/core@1.5.2 - ## 1.1.19 ### Patch Changes diff --git a/packages/ui/package.json b/packages/ui/package.json index f4d14b46e..1110698e3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@lightsparkdev/ui", - "version": "1.1.20", + "version": "1.1.19", "repository": { "type": "git", "url": "git+https://github.com/lightsparkdev/js-sdk.git" @@ -90,7 +90,7 @@ "@emotion/css": "^11.11.0", "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", - "@lightsparkdev/core": "1.5.2", + "@lightsparkdev/core": "1.5.1", "@rollup/plugin-url": "^8.0.2", "@simbathesailor/use-what-changed": "^2.0.0", "@svgr/core": "^8.1.0", diff --git a/packages/ui/tsdown.config.ts b/packages/ui/tsdown.config.ts index 9f70ec718..22f6851f6 100644 --- a/packages/ui/tsdown.config.ts +++ b/packages/ui/tsdown.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from "tsdown"; -import { svgr } from "./tsdown-svg-plugin.ts"; +import { svgr } from "./tsdown-svg-plugin"; export default defineConfig({ entry: [ From aefb119e51dd896d9405047f45aeeb1d1ccd07ea Mon Sep 17 00:00:00 2001 From: Jay Mantri Date: Thu, 30 Apr 2026 14:37:32 -0700 Subject: [PATCH 02/56] [grid] add typed wrapper for Origin Button (#26770) ## Summary - expose Origin Button's existing Base UI `render` / `nativeButton` support in its TypeScript props so product wrappers can render it as a typed router link without reimplementing visuals - add a focused Grid `NageButton` wrapper that only owns typed routing props (`to`, `toParams`, `hash`) around Origin Button - keep the branch intentionally narrow: no legacy shared UI Button import, no Emotion compatibility layer, no legacy prop mapping, and no consumer migration yet - add a Vitest contract test for routing and transparent Origin prop pass-through ## Validation - `yarn vitest run src/uma-nage/components/NageButton.test.tsx --environment jsdom` - `yarn tsc --noEmit --pretty false` in `js/apps/private/site` - `yarn types` in `js/packages/origin` - `yarn vite build` in `js/apps/private/site` GitOrigin-RevId: 633ace9159779598d69b44177bbfd3ba9ffe233a --- packages/origin/src/components/Button/Button.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/origin/src/components/Button/Button.tsx b/packages/origin/src/components/Button/Button.tsx index fc8070f3b..ce9f3ac73 100644 --- a/packages/origin/src/components/Button/Button.tsx +++ b/packages/origin/src/components/Button/Button.tsx @@ -7,8 +7,7 @@ import { Loader } from "../Loader"; import { useTrackedCallback } from "../Analytics/useTrackedCallback"; import styles from "./Button.module.scss"; -export interface ButtonProps - extends React.ButtonHTMLAttributes { +export interface ButtonProps extends BaseButton.Props { variant?: "filled" | "secondary" | "outline" | "ghost" | "critical" | "link"; size?: "default" | "compact" | "dense"; loading?: boolean; From 1e24a82bb0b83e1adc5277df35c89fe83bfc6320 Mon Sep 17 00:00:00 2001 From: Jay Mantri Date: Thu, 30 Apr 2026 14:38:23 -0700 Subject: [PATCH 03/56] DES-23: Add LoadMore infinite-scroll primitive + useLoadMore hook (#26920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Ships a new Origin compound primitive `LoadMore` and a transport-agnostic companion hook `useLoadMore` for forward-only infinite scroll. Third of three sibling pagination primitives under epic [DES-20](https://lightspark.atlassian.net/browse/DES-20) (after [DES-21](https://lightspark.atlassian.net/browse/DES-21) `Pagination` and [DES-22](https://lightspark.atlassian.net/browse/DES-22) `Pager`). Resolves [DES-23](https://lightspark.atlassian.net/browse/DES-23). ## Component API `LoadMore` follows the new Origin idiom standard — `forwardRef` everywhere, exported context hook (`useLoadMoreContext`), `data-*` state attributes, Base UI `useRender` `render` escape hatch on every overridable part. - **`Root`** — headless context provider over `{ hasMore, loading, onLoadMore, analyticsName }`. Renders only its children. - **`Trigger`** — composes Origin's `Button` by default; swap with `render={} /> + + ), +}; + +export const WithFilterReset: Story = { + render: () => { + const [filter, setFilter] = React.useState("all"); + const { items, hasMore, loading, loadingMore, loadMore } = + useLoadMore({ + fetchPage: async (cursor) => { + const offset = cursor ? Number(cursor) : 0; + await new Promise((r) => setTimeout(r, 300)); + const data = generatePage(offset, 5).map((item) => ({ + ...item, + label: `${filter}: ${item.label}`, + })); + const next = offset + 5; + return { + data, + nextCursor: next < 20 ? String(next) : undefined, + hasMore: next < 20, + }; + }, + resetOn: [filter], + }); + + return ( +
+
+ {["all", "starred", "archived"].map((value) => ( + + ))} +
+ + + + +
+ ); + }, +}; diff --git a/packages/origin/src/components/LoadMore/LoadMore.test-stories.tsx b/packages/origin/src/components/LoadMore/LoadMore.test-stories.tsx new file mode 100644 index 000000000..b37e21ab9 --- /dev/null +++ b/packages/origin/src/components/LoadMore/LoadMore.test-stories.tsx @@ -0,0 +1,233 @@ +"use client"; + +import * as React from "react"; +import { LoadMore } from "./LoadMore"; +import { Button } from "../Button"; +import { useLoadMore } from "./useLoadMore"; +import { AnalyticsProvider } from "../Analytics"; +import type { AnalyticsHandler, InteractionInfo } from "../Analytics"; + +interface CounterRefs { + loadCount: number; +} + +function ManualHarness({ + hasMore = true, + loading = false, +}: { + hasMore?: boolean; + loading?: boolean; +}) { + const [count, setCount] = React.useState(0); + return ( + setCount((c) => c + 1)} + > + +

Loads: {count}

+
+ ); +} + +export function TriggerEnabled() { + return ; +} + +export function TriggerNoMore() { + return ; +} + +export function TriggerLoading() { + return ; +} + +export function TriggerCustomRender() { + const [count, setCount] = React.useState(0); + return ( + setCount((c) => c + 1)} + > + Show more} /> +

Loads: {count}

+
+ ); +} + +function SentinelHarness({ + initialHasMore = true, + hold = false, +}: { + initialHasMore?: boolean; + hold?: boolean; +}) { + const [count, setCount] = React.useState(0); + const [hasMore, setHasMore] = React.useState(initialHasMore); + const [loading, setLoading] = React.useState(false); + + const onLoadMore = React.useCallback(() => { + setCount((c) => c + 1); + if (hold) { + setLoading(true); + return; + } + setLoading(true); + setTimeout(() => { + setLoading(false); + setHasMore(false); + }, 50); + }, [hold]); + + return ( +
+
+ + +

Loads: {count}

+
+
+ ); +} + +export function SentinelTriggersOnScroll() { + return ; +} + +export function SentinelDoesNotRefireWhileLoading() { + return ; +} + +export function SentinelDisabled() { + return ( + undefined}> + + + ); +} + +export function StatusAnnouncements({ + hasMore = true, + loading = false, +}: { + hasMore?: boolean; + loading?: boolean; +}) { + return ( + undefined} + > + + {({ loading, hasMore }) => + loading + ? "Loading more results" + : !hasMore + ? "End of results" + : "More available" + } + + + ); +} + +export function StatusLoading() { + return ; +} + +export function StatusEnd() { + return ; +} + +export function ContextOutsideRoot() { + return ( + + + + ); +} + +class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + { error: Error | null } +> { + state = { error: null as Error | null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + if (this.state.error) { + return
{this.state.error.message}
; + } + return this.props.children; + } +} + +export function AnalyticsTrigger() { + const [events, setEvents] = React.useState([]); + const handler = React.useMemo( + () => ({ + onInteraction: (info) => setEvents((prev) => [...prev, info]), + }), + [], + ); + + return ( + + undefined} + analyticsName="results" + > + + +
{JSON.stringify(events)}
+
+ ); +} + +export function HookIntegration() { + const fetchPage = React.useCallback(async (cursor: string | undefined) => { + const offset = cursor ? Number(cursor) : 0; + const data = Array.from({ length: 5 }, (_, i) => ({ + id: `${offset + i}`, + })); + const next = offset + 5; + return { + data, + nextCursor: next < 15 ? String(next) : undefined, + hasMore: next < 15, + }; + }, []); + + const { items, hasMore, loading, loadingMore, loadMore } = useLoadMore<{ + id: string; + }>({ + fetchPage, + }); + + return ( +
+
    + {items.map((item) => ( +
  • {item.id}
  • + ))} +
+ + + +
+ ); +} diff --git a/packages/origin/src/components/LoadMore/LoadMore.test.tsx b/packages/origin/src/components/LoadMore/LoadMore.test.tsx new file mode 100644 index 000000000..3cb9e9dee --- /dev/null +++ b/packages/origin/src/components/LoadMore/LoadMore.test.tsx @@ -0,0 +1,167 @@ +import { test, expect } from "@playwright/experimental-ct-react"; +import { + TriggerEnabled, + TriggerNoMore, + TriggerLoading, + TriggerCustomRender, + SentinelTriggersOnScroll, + SentinelDoesNotRefireWhileLoading, + SentinelDisabled, + StatusAnnouncements, + StatusLoading, + StatusEnd, + ContextOutsideRoot, + AnalyticsTrigger, + HookIntegration, +} from "./LoadMore.test-stories"; + +test.describe("LoadMore.Trigger", () => { + test("calls onLoadMore on click and increments the counter", async ({ + mount, + page, + }) => { + await mount(); + const trigger = page.getByRole("button", { name: /load more/i }); + await expect(trigger).toBeEnabled(); + await expect(trigger).toHaveAttribute("data-has-more", "true"); + await trigger.click(); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 1"); + }); + + test("is disabled when hasMore is false", async ({ mount, page }) => { + await mount(); + const trigger = page.getByRole("button", { name: /load more/i }); + await expect(trigger).toBeDisabled(); + await expect(trigger).toHaveAttribute("data-disabled", "true"); + await expect(trigger).not.toHaveAttribute("data-has-more", "true"); + }); + + test("is disabled and aria-busy while loading", async ({ mount, page }) => { + await mount(); + const trigger = page.getByRole("button"); + await expect(trigger).toBeDisabled(); + await expect(trigger).toHaveAttribute("aria-busy", "true"); + await expect(trigger).toHaveAttribute("data-loading", "true"); + }); + + test("render prop swaps the underlying element and still tracks clicks", async ({ + mount, + page, + }) => { + await mount(); + const trigger = page.getByRole("button", { name: /show more/i }); + await expect(trigger).toBeVisible(); + await trigger.click(); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 1"); + }); +}); + +test.describe("LoadMore.Sentinel", () => { + test("calls onLoadMore when scrolled into view", async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 0"); + await page.evaluate(() => + window.scrollTo({ top: document.body.scrollHeight, behavior: "instant" }), + ); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 1"); + // Stays at 1 — hasMore is now false after the timeout completes. + await page.waitForTimeout(150); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 1"); + }); + + test("does not refire while loading is held true", async ({ + mount, + page, + }) => { + await mount(); + await page.evaluate(() => + window.scrollTo({ top: document.body.scrollHeight, behavior: "instant" }), + ); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 1"); + await page.waitForTimeout(200); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 1"); + }); + + test("renders no DOM when disabled", async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId("sentinel")).toHaveCount(0); + }); +}); + +test.describe("LoadMore.Status", () => { + test("renders 'More available' by default with aria-live polite", async ({ + mount, + page, + }) => { + await mount(); + const status = page.getByTestId("status"); + await expect(status).toHaveAttribute("aria-live", "polite"); + await expect(status).toHaveAttribute("aria-atomic", "true"); + await expect(status).toHaveText("More available"); + }); + + test("announces loading text", async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId("status")).toHaveText("Loading more results"); + await expect(page.getByTestId("status")).toHaveAttribute( + "data-loading", + "true", + ); + }); + + test("announces end-of-results text", async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId("status")).toHaveText("End of results"); + await expect(page.getByTestId("status")).toHaveAttribute( + "data-end", + "true", + ); + }); +}); + +test.describe("Context safety", () => { + test("Trigger throws when used outside Root", async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId("error")).toHaveText( + /must be placed within /, + ); + }); +}); + +test.describe("Analytics", () => { + test("emits LoadMore.click with part metadata when analyticsName is set", async ({ + mount, + page, + }) => { + await mount(); + await page.getByRole("button", { name: /load more/i }).click(); + const log = await page.getByTestId("analytics-log").textContent(); + expect(log).toBeTruthy(); + const events = JSON.parse(log ?? "[]"); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + name: "results", + component: "LoadMore", + interaction: "click", + metadata: { part: "trigger" }, + }); + }); +}); + +test.describe("Hook integration", () => { + test("paginates via useLoadMore until hasMore is false", async ({ + mount, + page, + }) => { + await mount(); + await expect(page.getByTestId("items").locator("li")).toHaveCount(5); + + const trigger = page.getByRole("button", { name: /load more/i }); + await trigger.click(); + await expect(page.getByTestId("items").locator("li")).toHaveCount(10); + + await trigger.click(); + await expect(page.getByTestId("items").locator("li")).toHaveCount(15); + await expect(trigger).toBeDisabled(); + }); +}); diff --git a/packages/origin/src/components/LoadMore/LoadMore.tsx b/packages/origin/src/components/LoadMore/LoadMore.tsx new file mode 100644 index 000000000..17b07f19b --- /dev/null +++ b/packages/origin/src/components/LoadMore/LoadMore.tsx @@ -0,0 +1,432 @@ +"use client"; + +import * as React from "react"; +import { Button, type ButtonProps } from "../Button"; +import { useTrackedCallback } from "../Analytics/useTrackedCallback"; +import { useRender, mergeProps } from "../../lib/base-ui-utils"; +import styles from "./LoadMore.module.scss"; + +export interface LoadMoreContextValue { + hasMore: boolean; + loading: boolean; + onLoadMore: () => void; + analyticsName: string | undefined; +} + +const LoadMoreContext = React.createContext(null); + +/** Access the surrounding `LoadMore.Root` state. Throws if used outside one. */ +export function useLoadMoreContext(): LoadMoreContextValue { + const context = React.useContext(LoadMoreContext); + if (context === null) { + throw new Error("LoadMore parts must be placed within ."); + } + return context; +} + +export interface LoadMoreRootProps { + /** Whether more items are available. */ + hasMore: boolean; + /** + * Whether a load is currently in flight. Trigger and Sentinel use this to + * disable themselves and prevent re-firing. + */ + loading: boolean; + /** Called when the user (or sentinel intersection) requests another page. */ + onLoadMore: () => void; + /** + * Optional analytics identifier. Trigger emits `${name}.click` (interaction + * `click`) and Sentinel emits `${name}.intersect` (interaction `intersect`) + * with metadata `{ part: "trigger" | "sentinel" }`. + */ + analyticsName?: string; + children?: React.ReactNode; +} + +/** Headless context provider — renders only its children. */ +export function LoadMoreRoot(props: LoadMoreRootProps) { + const { hasMore, loading, onLoadMore, analyticsName, children } = props; + + const value = React.useMemo( + () => ({ hasMore, loading, onLoadMore, analyticsName }), + [hasMore, loading, onLoadMore, analyticsName], + ); + + return ( + + {children} + + ); +} + +type TriggerRenderState = { + hasMore: boolean; + loading: boolean; + disabled: boolean; +}; + +type TriggerRenderProp = useRender.RenderProp; + +export interface LoadMoreTriggerProps + extends Omit { + /** + * Override the auto-derived disabled state (`!hasMore || loading`). Pass + * `false` to force-enable; pass `true` to force-disable. + */ + disabled?: boolean; + /** + * Replace the default `Button` element. Receives the merged click/disabled + * props the trigger would otherwise pass to `Button`. + */ + render?: TriggerRenderProp; + /** Visible label. Defaults to `"Load more"`. */ + children?: React.ReactNode; +} + +interface RenderTriggerProps { + render: TriggerRenderProp; + state: TriggerRenderState; + forwardedProps: Record; + onClick: (event: React.MouseEvent) => void; + isDisabled: boolean; + loading: boolean; + forwardedRef: React.ForwardedRef; +} + +function RenderTrigger({ + render, + state, + forwardedProps, + onClick, + isDisabled, + loading, + forwardedRef, +}: RenderTriggerProps) { + const internalProps = { + onClick, + disabled: isDisabled, + "aria-busy": loading || undefined, + "data-loading": loading || undefined, + "data-has-more": state.hasMore || undefined, + "data-disabled": isDisabled || undefined, + } as React.ComponentPropsWithRef<"button">; + return useRender({ + render, + ref: forwardedRef as React.Ref, + state, + props: mergeProps<"button">( + internalProps, + forwardedProps as React.ComponentPropsWithRef<"button">, + ), + }); +} + +/** Manual button trigger. Defaults to Origin's `Button`. */ +export const LoadMoreTrigger = React.forwardRef< + HTMLButtonElement, + LoadMoreTriggerProps +>(function LoadMoreTrigger(props, forwardedRef) { + const { disabled, render, children = "Load more", ...rest } = props; + const { hasMore, loading, onLoadMore, analyticsName } = useLoadMoreContext(); + const isDisabled = disabled ?? (!hasMore || loading); + + const handleClick = useTrackedCallback( + analyticsName, + "LoadMore", + "click", + () => { + if (isDisabled) return; + onLoadMore(); + }, + () => ({ part: "trigger" }), + ); + + if (render) { + return ( + } + onClick={handleClick} + isDisabled={isDisabled} + loading={loading} + forwardedRef={forwardedRef} + /> + ); + } + + return ( + + ); +}); + +export interface LoadMoreSentinelProps + extends React.HTMLAttributes { + /** + * IntersectionObserver root. Defaults to the viewport. Pass a scroll + * container to scope observations to a scrolling region. + */ + root?: Element | Document | null; + /** Defaults to `"0px 0px 200px 0px"` — preload 200px before reaching the sentinel. */ + rootMargin?: string; + /** Defaults to `0`. */ + threshold?: number | number[]; + /** + * Disable the observer entirely. When `true` no DOM is rendered, so callers + * can fall back to a manual `Trigger`. + */ + disabled?: boolean; + /** Override the rendered element. */ + render?: useRender.RenderProp<{ hasMore: boolean; loading: boolean }>; +} + +/** Invisible viewport-intersection trigger for infinite scroll. */ +export const LoadMoreSentinel = React.forwardRef< + HTMLDivElement, + LoadMoreSentinelProps +>(function LoadMoreSentinel(props, forwardedRef) { + const { + root = null, + rootMargin = "0px 0px 200px 0px", + threshold = 0, + disabled, + render, + className, + ...rest + } = props; + const { hasMore, loading, onLoadMore, analyticsName } = useLoadMoreContext(); + + const onLoadMoreRef = React.useRef(onLoadMore); + onLoadMoreRef.current = onLoadMore; + const loadingRef = React.useRef(loading); + loadingRef.current = loading; + const hasMoreRef = React.useRef(hasMore); + hasMoreRef.current = hasMore; + + const trackedIntersect = useTrackedCallback( + analyticsName, + "LoadMore", + "intersect", + () => onLoadMoreRef.current(), + () => ({ part: "sentinel" }), + ); + + const isMountedRef = React.useRef(false); + + const localRef = React.useRef(null); + const setRef = React.useCallback( + (node: HTMLDivElement | null) => { + localRef.current = node; + if (typeof forwardedRef === "function") { + forwardedRef(node); + } else if (forwardedRef) { + forwardedRef.current = node; + } + }, + [forwardedRef], + ); + + React.useEffect(() => { + if (disabled) return; + const node = localRef.current; + if (!node || typeof IntersectionObserver === "undefined") return; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + if (loadingRef.current) continue; + if (!hasMoreRef.current) continue; + trackedIntersect(); + break; + } + }, + { root: root ?? null, rootMargin, threshold }, + ); + + observer.observe(node); + return () => observer.disconnect(); + }, [disabled, root, rootMargin, threshold, trackedIntersect]); + + // After loading flips false, re-evaluate intersection in case the new page + // didn't grow tall enough to scroll the sentinel out of view. Skipped on + // initial mount so we don't double-fire alongside the IntersectionObserver + // setup effect when the sentinel is already in view. + React.useEffect(() => { + if (!isMountedRef.current) { + isMountedRef.current = true; + return; + } + if (loading || !hasMore || disabled) return; + const node = localRef.current; + if (!node || typeof window === "undefined") return; + const rect = node.getBoundingClientRect(); + const inView = rect.top < window.innerHeight && rect.bottom > 0; + if (inView) trackedIntersect(); + }, [loading, hasMore, disabled, trackedIntersect]); + + if (disabled) return null; + + const baseProps = { + "aria-hidden": true as const, + role: "presentation" as const, + "data-loading": loading || undefined, + "data-active": (hasMore && !loading) || undefined, + className: [styles.sentinel, className].filter(Boolean).join(" "), + }; + + if (render) { + return ( + } + setRef={setRef} + /> + ); + } + + return
; +}); + +interface RenderSentinelProps { + render: useRender.RenderProp<{ hasMore: boolean; loading: boolean }>; + state: { hasMore: boolean; loading: boolean }; + baseProps: Record; + forwardedProps: Record; + setRef: React.RefCallback; +} + +function RenderSentinel({ + render, + state, + baseProps, + forwardedProps, + setRef, +}: RenderSentinelProps) { + return useRender({ + render, + ref: setRef, + state, + props: mergeProps<"div">( + baseProps as React.ComponentPropsWithRef<"div">, + forwardedProps as React.ComponentPropsWithRef<"div">, + ), + }); +} + +type StatusRenderState = { loading: boolean; hasMore: boolean }; + +export interface LoadMoreStatusProps + extends Omit, "children"> { + /** + * Either static content, or a render function that receives the current + * load state and returns the announcement text. + */ + children?: React.ReactNode | ((state: StatusRenderState) => React.ReactNode); + /** Defaults to `"polite"`. */ + "aria-live"?: "polite" | "assertive" | "off"; + render?: useRender.RenderProp<{ hasMore: boolean; loading: boolean }>; +} + +/** SR-only `aria-live` announcement slot. */ +export const LoadMoreStatus = React.forwardRef< + HTMLDivElement, + LoadMoreStatusProps +>(function LoadMoreStatus(props, forwardedRef) { + const { + children, + "aria-live": ariaLive = "polite", + render, + className, + ...rest + } = props; + const { hasMore, loading } = useLoadMoreContext(); + + const content = + typeof children === "function" + ? (children as (state: StatusRenderState) => React.ReactNode)({ + loading, + hasMore, + }) + : children; + + const baseProps = { + "aria-live": ariaLive, + "aria-atomic": true as const, + "data-loading": loading || undefined, + "data-end": !hasMore || undefined, + className: [styles.status, className].filter(Boolean).join(" "), + }; + + if (render) { + return ( + + ); + } + + return ( +
+ {content} +
+ ); +}); + +interface RenderStatusProps { + render: useRender.RenderProp<{ hasMore: boolean; loading: boolean }>; + state: { hasMore: boolean; loading: boolean }; + baseProps: Record; + forwardedProps: Record; + forwardedRef: React.ForwardedRef; +} + +function RenderStatus({ + render, + state, + baseProps, + forwardedProps, + forwardedRef, +}: RenderStatusProps) { + return useRender({ + render, + ref: forwardedRef as React.Ref, + state, + props: mergeProps<"div">( + baseProps as React.ComponentPropsWithRef<"div">, + forwardedProps as React.ComponentPropsWithRef<"div">, + ), + }); +} + +if (process.env.NODE_ENV !== "production") { + LoadMoreTrigger.displayName = "LoadMoreTrigger"; + LoadMoreSentinel.displayName = "LoadMoreSentinel"; + LoadMoreStatus.displayName = "LoadMoreStatus"; +} + +export const LoadMore = { + Root: LoadMoreRoot, + Trigger: LoadMoreTrigger, + Sentinel: LoadMoreSentinel, + Status: LoadMoreStatus, +}; + +export default LoadMore; diff --git a/packages/origin/src/components/LoadMore/LoadMore.unit.test.tsx b/packages/origin/src/components/LoadMore/LoadMore.unit.test.tsx new file mode 100644 index 000000000..6f84630c5 --- /dev/null +++ b/packages/origin/src/components/LoadMore/LoadMore.unit.test.tsx @@ -0,0 +1,81 @@ +/** + * LoadMore Unit Tests (Vitest + @testing-library/react) + * + * For real browser testing (IntersectionObserver, scroll, accessibility), + * see LoadMore.test.tsx (Playwright CT). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render } from "@testing-library/react"; +import * as React from "react"; +import { LoadMore } from "./LoadMore"; + +type ObserverCallback = ( + entries: IntersectionObserverEntry[], + observer: IntersectionObserver, +) => void; + +interface MockObserver { + observe: ReturnType; + unobserve: ReturnType; + disconnect: ReturnType; + takeRecords: ReturnType; + callback: ObserverCallback; +} + +const observers: MockObserver[] = []; + +beforeEach(() => { + observers.length = 0; + class MockIntersectionObserver implements MockObserver { + callback: ObserverCallback; + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(() => []); + + constructor(callback: ObserverCallback) { + this.callback = callback; + observers.push(this); + } + } + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function fireIntersect(observer: MockObserver) { + const target = observer.observe.mock.calls[0]?.[0] as Element; + observer.callback( + [ + { + isIntersecting: true, + target, + intersectionRatio: 1, + boundingClientRect: target.getBoundingClientRect(), + intersectionRect: target.getBoundingClientRect(), + rootBounds: null, + time: 0, + } as IntersectionObserverEntry, + ], + observer as unknown as IntersectionObserver, + ); +} + +describe("LoadMore.Sentinel initial mount", () => { + it("fires onLoadMore exactly once when the sentinel mounts already in view", () => { + const onLoadMore = vi.fn(); + render( + + + , + ); + + expect(observers).toHaveLength(1); + fireIntersect(observers[0]); + + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/origin/src/components/LoadMore/index.ts b/packages/origin/src/components/LoadMore/index.ts new file mode 100644 index 000000000..26baa0074 --- /dev/null +++ b/packages/origin/src/components/LoadMore/index.ts @@ -0,0 +1,14 @@ +export { LoadMore, useLoadMoreContext } from "./LoadMore"; +export type { + LoadMoreRootProps, + LoadMoreTriggerProps, + LoadMoreSentinelProps, + LoadMoreStatusProps, + LoadMoreContextValue, +} from "./LoadMore"; +export { useLoadMore } from "./useLoadMore"; +export type { + UseLoadMoreOptions, + UseLoadMoreResult, + UseLoadMoreFetchResult, +} from "./useLoadMore"; diff --git a/packages/origin/src/components/LoadMore/useLoadMore.ts b/packages/origin/src/components/LoadMore/useLoadMore.ts new file mode 100644 index 000000000..a325d2a93 --- /dev/null +++ b/packages/origin/src/components/LoadMore/useLoadMore.ts @@ -0,0 +1,150 @@ +"use client"; + +import * as React from "react"; + +export interface UseLoadMoreFetchResult { + data: T[]; + /** Cursor for the next page. Omit when there is no next page. */ + nextCursor?: TCursor; + /** Whether `loadMore` should be enabled after this page. */ + hasMore: boolean; +} + +export interface UseLoadMoreOptions { + /** + * Fetches a page. Receives the cursor from the previous page, or `undefined` + * for the initial fetch (and after `refetch`/`resetOn` change). Reject the + * promise to surface an error in `result.error`. + */ + fetchPage: ( + cursor: TCursor | undefined, + ) => Promise>; + /** + * When any value changes (by `JSON.stringify` value), pagination resets and + * an initial fetch is kicked off. Values must be JSON-serializable; for + * object dependencies, pass a stable id. Defaults to `[]` (fetch once). + */ + resetOn?: React.DependencyList; + /** Skip the initial fetch when `false`. Defaults to `true`. */ + enabled?: boolean; + /** Starting cursor for the first page. */ + initialCursor?: TCursor; +} + +export interface UseLoadMoreResult { + items: T[]; + /** True only during the initial fetch (and after refetch/reset). */ + loading: boolean; + /** True only during subsequent (`loadMore`) fetches. */ + loadingMore: boolean; + hasMore: boolean; + error: Error | undefined; + nextCursor: TCursor | undefined; + /** No-op when `!hasMore`, `loading`, or `loadingMore`. */ + loadMore: () => void; + /** Resets accumulated items and re-fetches the first page. */ + refetch: () => void; +} + +/** + * Transport-agnostic infinite-scroll pagination state. Pair with + * `LoadMore.Sentinel` / `LoadMore.Trigger` to drive a forward-only paginated + * list. Stale responses are dropped via an internal request id so concurrent + * `refetch`/`resetOn` changes never clobber newer state. + */ +export function useLoadMore( + options: UseLoadMoreOptions, +): UseLoadMoreResult { + const { fetchPage, resetOn, enabled = true, initialCursor } = options; + + const [items, setItems] = React.useState([]); + const [loading, setLoading] = React.useState(enabled); + const [loadingMore, setLoadingMore] = React.useState(false); + const [error, setError] = React.useState(undefined); + const [nextCursor, setNextCursor] = React.useState( + initialCursor, + ); + const [hasMore, setHasMore] = React.useState(true); + + const fetchPageRef = React.useRef(fetchPage); + fetchPageRef.current = fetchPage; + + const requestIdRef = React.useRef(0); + + const runFetch = React.useCallback( + async (cursor: TCursor | undefined, isInitial: boolean) => { + const reqId = ++requestIdRef.current; + if (isInitial) { + setLoading(true); + } else { + setLoadingMore(true); + } + setError(undefined); + try { + const result = await fetchPageRef.current(cursor); + if (reqId !== requestIdRef.current) return; + setItems((prev) => + isInitial ? result.data : [...prev, ...result.data], + ); + setHasMore(result.hasMore); + setNextCursor(result.nextCursor); + } catch (e) { + if (reqId !== requestIdRef.current) return; + setError(e instanceof Error ? e : new Error(String(e))); + } finally { + if (reqId === requestIdRef.current) { + setLoading(false); + setLoadingMore(false); + } + } + }, + [], + ); + + const refetch = React.useCallback(() => { + setItems([]); + setNextCursor(initialCursor); + setHasMore(true); + void runFetch(initialCursor, true); + }, [runFetch, initialCursor]); + + // JSON.stringify gives us value-equality semantics for the dep array, + // matching the pattern in useGridApiPaginatedQuery. + const resetKey = React.useMemo( + () => JSON.stringify(resetOn ?? []), + [resetOn], + ); + + React.useEffect(() => { + if (!enabled) { + requestIdRef.current++; + setLoading(false); + setLoadingMore(false); + return; + } + setItems([]); + setNextCursor(initialCursor); + setHasMore(true); + void runFetch(initialCursor, true); + // initialCursor intentionally excluded: it's used only as a starting value + // and changing it shouldn't on its own re-fetch (callers can pass it in + // resetOn if they want that behavior). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, resetKey, runFetch]); + + const loadMore = React.useCallback(() => { + if (!hasMore || loading || loadingMore) return; + void runFetch(nextCursor, false); + }, [hasMore, loading, loadingMore, nextCursor, runFetch]); + + return { + items, + loading, + loadingMore, + hasMore, + error, + nextCursor, + loadMore, + refetch, + }; +} diff --git a/packages/origin/src/components/LoadMore/useLoadMore.unit.test.ts b/packages/origin/src/components/LoadMore/useLoadMore.unit.test.ts new file mode 100644 index 000000000..224d6f98d --- /dev/null +++ b/packages/origin/src/components/LoadMore/useLoadMore.unit.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useLoadMore, type UseLoadMoreFetchResult } from "./useLoadMore"; + +type Item = { id: string }; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("useLoadMore", () => { + it("fetches the first page on mount and exposes its items", async () => { + const fetchPage = vi.fn( + async ( + cursor: string | undefined, + ): Promise> => ({ + data: [{ id: cursor ?? "a" }], + nextCursor: "b", + hasMore: true, + }), + ); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + + expect(result.current.loading).toBe(true); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(fetchPage).toHaveBeenCalledTimes(1); + expect(fetchPage).toHaveBeenLastCalledWith(undefined); + expect(result.current.items).toEqual([{ id: "a" }]); + expect(result.current.hasMore).toBe(true); + expect(result.current.nextCursor).toBe("b"); + expect(result.current.error).toBeUndefined(); + }); + + it("does not fetch when enabled is false; toggling true triggers a fetch", async () => { + const fetchPage = vi.fn( + async (): Promise> => ({ + data: [{ id: "x" }], + hasMore: false, + }), + ); + + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => + useLoadMore({ fetchPage, enabled }), + { initialProps: { enabled: false } }, + ); + + expect(result.current.loading).toBe(false); + expect(fetchPage).not.toHaveBeenCalled(); + + rerender({ enabled: true }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(fetchPage).toHaveBeenCalledTimes(1); + expect(result.current.items).toEqual([{ id: "x" }]); + }); + + it("accumulates items across loadMore calls and forwards the cursor", async () => { + const pages: Record> = { + first: { data: [{ id: "1" }], nextCursor: "p2", hasMore: true }, + p2: { data: [{ id: "2" }], nextCursor: "p3", hasMore: true }, + p3: { data: [{ id: "3" }], hasMore: false }, + }; + const fetchPage = vi.fn(async (cursor: string | undefined) => { + return pages[cursor ?? "first"]; + }); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "1" }]); + + act(() => { + result.current.loadMore(); + }); + await waitFor(() => expect(result.current.loadingMore).toBe(false)); + expect(result.current.items).toEqual([{ id: "1" }, { id: "2" }]); + expect(fetchPage).toHaveBeenLastCalledWith("p2"); + + act(() => { + result.current.loadMore(); + }); + await waitFor(() => expect(result.current.loadingMore).toBe(false)); + expect(result.current.items).toEqual([ + { id: "1" }, + { id: "2" }, + { id: "3" }, + ]); + expect(result.current.hasMore).toBe(false); + }); + + it("treats loadMore as a no-op when hasMore is false", async () => { + const fetchPage = vi.fn( + async (): Promise> => ({ + data: [{ id: "only" }], + hasMore: false, + }), + ); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.loadMore(); + }); + + expect(fetchPage).toHaveBeenCalledTimes(1); + }); + + it("treats a second loadMore as a no-op while one is in flight", async () => { + const initial = deferred>(); + const next = deferred>(); + let call = 0; + const fetchPage = vi.fn(async () => { + call += 1; + return call === 1 ? initial.promise : next.promise; + }); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + initial.resolve({ data: [{ id: "1" }], nextCursor: "n", hasMore: true }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.loadMore(); + }); + expect(result.current.loadingMore).toBe(true); + + act(() => { + result.current.loadMore(); + }); + expect(fetchPage).toHaveBeenCalledTimes(2); + + await act(async () => { + next.resolve({ data: [{ id: "2" }], hasMore: false }); + await next.promise; + }); + await waitFor(() => expect(result.current.loadingMore).toBe(false)); + expect(result.current.items).toEqual([{ id: "1" }, { id: "2" }]); + }); + + it("drops stale responses when refetch races a slow first page", async () => { + const slow = deferred>(); + const fresh = deferred>(); + let call = 0; + const fetchPage = vi.fn(async () => { + call += 1; + return call === 1 ? slow.promise : fresh.promise; + }); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + + act(() => { + result.current.refetch(); + }); + + await act(async () => { + fresh.resolve({ data: [{ id: "fresh" }], hasMore: false }); + await fresh.promise; + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "fresh" }]); + + await act(async () => { + slow.resolve({ data: [{ id: "stale" }], hasMore: true }); + await slow.promise; + }); + + expect(result.current.items).toEqual([{ id: "fresh" }]); + expect(result.current.hasMore).toBe(false); + }); + + it("resets accumulated state when resetOn changes", async () => { + const fetchPage = vi.fn( + async ( + cursor: string | undefined, + ): Promise> => ({ + data: [{ id: cursor ?? "first" }], + hasMore: false, + }), + ); + + const { result, rerender } = renderHook( + ({ filter }: { filter: string }) => + useLoadMore({ fetchPage, resetOn: [filter] }), + { initialProps: { filter: "a" } }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "first" }]); + expect(fetchPage).toHaveBeenCalledTimes(1); + + rerender({ filter: "b" }); + + await waitFor(() => expect(fetchPage).toHaveBeenCalledTimes(2)); + expect(fetchPage).toHaveBeenLastCalledWith(undefined); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "first" }]); + }); + + it("refetch clears items and re-fetches the first page", async () => { + let call = 0; + const fetchPage = vi.fn( + async ( + cursor: string | undefined, + ): Promise> => { + call += 1; + if (cursor === undefined) { + return { data: [{ id: `init-${call}` }], hasMore: false }; + } + return { data: [], hasMore: false }; + }, + ); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "init-1" }]); + + act(() => { + result.current.refetch(); + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "init-2" }]); + expect(fetchPage).toHaveBeenCalledTimes(2); + }); + + it("surfaces fetch errors and preserves prior items", async () => { + let call = 0; + const fetchPage = vi.fn( + async ( + cursor: string | undefined, + ): Promise> => { + call += 1; + if (call === 1) { + return { data: [{ id: "1" }], nextCursor: "n", hasMore: true }; + } + throw new Error("boom"); + }, + ); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "1" }]); + + act(() => { + result.current.loadMore(); + }); + await waitFor(() => expect(result.current.loadingMore).toBe(false)); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe("boom"); + expect(result.current.items).toEqual([{ id: "1" }]); + }); + + it("clears the error on the next fetch", async () => { + let call = 0; + const fetchPage = vi.fn(async (): Promise> => { + call += 1; + if (call === 1) throw new Error("first"); + return { data: [{ id: "after-retry" }], hasMore: false }; + }); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error?.message).toBe("first"); + + act(() => { + result.current.refetch(); + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBeUndefined(); + expect(result.current.items).toEqual([{ id: "after-retry" }]); + }); +}); diff --git a/packages/origin/src/index.ts b/packages/origin/src/index.ts index fe91d4bf4..3a79cea5f 100644 --- a/packages/origin/src/index.ts +++ b/packages/origin/src/index.ts @@ -215,6 +215,22 @@ export type { LogoProps } from "./components/Logo"; export { Loader } from "./components/Loader"; export type { LoaderProps } from "./components/Loader"; +export { + LoadMore, + useLoadMore, + useLoadMoreContext, +} from "./components/LoadMore"; +export type { + LoadMoreRootProps, + LoadMoreTriggerProps, + LoadMoreSentinelProps, + LoadMoreStatusProps, + LoadMoreContextValue, + UseLoadMoreOptions, + UseLoadMoreResult, + UseLoadMoreFetchResult, +} from "./components/LoadMore"; + export { Separator } from "./components/Separator"; export type { SeparatorProps } from "./components/Separator"; From 497e6f92c13c9b2ee79671f28993d41fdabf0bc4 Mon Sep 17 00:00:00 2001 From: Jay Mantri Date: Thu, 30 Apr 2026 15:47:48 -0700 Subject: [PATCH 04/56] =?UTF-8?q?fix(origin):=20unblock=20site=20builds=20?= =?UTF-8?q?=E2=80=94=20LoadMoreTriggerProps=20render=20override=20conflict?= =?UTF-8?q?=20(TS2430)=20(#26931)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What's broken `LoadMoreTriggerProps` in `js/packages/origin/src/components/LoadMore/LoadMore.tsx` extends `Omit` and then redeclares `render` with a wider state type (`TriggerRenderState` adds `hasMore` and `loading` on top of `ButtonState`). Because `Omit` doesn't drop `render`, TypeScript flags the override as incompatible: ``` TS2430: Interface 'LoadMoreTriggerProps' incorrectly extends interface 'Omit'. Types of property 'render' are incompatible. Type 'ButtonState' is missing the following properties from type 'TriggerRenderState': hasMore, loading ``` This is currently failing the site app's `tsc` (run during `yarn build`) on every open PR. ## The fix Add `"render"` to the `Omit` clause so the trigger's wider render-state declaration is the only one on `LoadMoreTriggerProps`: ```ts export interface LoadMoreTriggerProps extends Omit { ``` One-token change. ## Why it slipped past origin's tests DES-23 (#26920) introduced the regression. Origin's `test:unit` runs vitest but does not type-check the site app, so the conflict only surfaces when `apps/private/site` runs `tsc` as part of `yarn build`. ## Verification - `yarn workspace @lightsparkdev/origin test:unit` → 447 tests pass - `yarn workspace @lightsparkdev/origin lint && … format` → clean (only pre-existing warnings) - `cd apps/private/site && find . -maxdepth 3 -name 'tsconfig.tsbuildinfo' -delete && yarn tsc` → passes cleanly, no `LoadMore` errors ## Urgency Blocking the site build on all open PRs — please land ASAP. Made with [Cursor](https://cursor.com) GitOrigin-RevId: c77577a1f91e3e9c6f2e86b31124021c29175e29 --- packages/origin/src/components/LoadMore/LoadMore.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/origin/src/components/LoadMore/LoadMore.tsx b/packages/origin/src/components/LoadMore/LoadMore.tsx index 17b07f19b..2afabc3b3 100644 --- a/packages/origin/src/components/LoadMore/LoadMore.tsx +++ b/packages/origin/src/components/LoadMore/LoadMore.tsx @@ -68,7 +68,7 @@ type TriggerRenderState = { type TriggerRenderProp = useRender.RenderProp; export interface LoadMoreTriggerProps - extends Omit { + extends Omit { /** * Override the auto-derived disabled state (`!hasMore || loading`). Pass * `false` to force-enable; pass `true` to force-disable. From 62d30b87fc12a009fb9627a0fdf7d0546d48ad23 Mon Sep 17 00:00:00 2001 From: Lightspark Eng Date: Fri, 1 May 2026 18:19:33 +0000 Subject: [PATCH 05/56] CI update lock file for PR --- yarn.lock | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/yarn.lock b/yarn.lock index e44be6198..a9ab35766 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3875,7 +3875,7 @@ __metadata: languageName: node linkType: hard -"@lightsparkdev/core@npm:1.5.2, @lightsparkdev/core@workspace:packages/core": +"@lightsparkdev/core@npm:1.5.1, @lightsparkdev/core@workspace:packages/core": version: 0.0.0-use.local resolution: "@lightsparkdev/core@workspace:packages/core" dependencies: @@ -3908,11 +3908,11 @@ __metadata: languageName: unknown linkType: soft -"@lightsparkdev/crypto-wasm@npm:0.1.26, @lightsparkdev/crypto-wasm@workspace:packages/crypto-wasm": +"@lightsparkdev/crypto-wasm@npm:0.1.25, @lightsparkdev/crypto-wasm@workspace:packages/crypto-wasm": version: 0.0.0-use.local resolution: "@lightsparkdev/crypto-wasm@workspace:packages/crypto-wasm" dependencies: - "@lightsparkdev/core": "npm:1.5.2" + "@lightsparkdev/core": "npm:1.5.1" jest: "npm:^29.6.2" ts-jest: "npm:^29.1.1" typescript: "npm:^5.6.2" @@ -3948,10 +3948,10 @@ __metadata: resolution: "@lightsparkdev/lightspark-cli@workspace:packages/lightspark-cli" dependencies: "@inquirer/prompts": "npm:^1.1.3" - "@lightsparkdev/core": "npm:1.5.2" - "@lightsparkdev/crypto-wasm": "npm:0.1.26" + "@lightsparkdev/core": "npm:1.5.1" + "@lightsparkdev/crypto-wasm": "npm:0.1.25" "@lightsparkdev/eslint-config": "npm:*" - "@lightsparkdev/lightspark-sdk": "npm:1.9.19" + "@lightsparkdev/lightspark-sdk": "npm:1.9.18" "@lightsparkdev/tsconfig": "npm:0.0.1" "@noble/curves": "npm:^1.9.7" "@types/jsonwebtoken": "npm:^9.0.2" @@ -3977,13 +3977,13 @@ __metadata: languageName: unknown linkType: soft -"@lightsparkdev/lightspark-sdk@npm:1.9.19, @lightsparkdev/lightspark-sdk@workspace:packages/lightspark-sdk": +"@lightsparkdev/lightspark-sdk@npm:1.9.18, @lightsparkdev/lightspark-sdk@workspace:packages/lightspark-sdk": version: 0.0.0-use.local resolution: "@lightsparkdev/lightspark-sdk@workspace:packages/lightspark-sdk" dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" - "@lightsparkdev/core": "npm:1.5.2" - "@lightsparkdev/crypto-wasm": "npm:0.1.26" + "@lightsparkdev/core": "npm:1.5.1" + "@lightsparkdev/crypto-wasm": "npm:0.1.25" "@lightsparkdev/eslint-config": "npm:*" "@lightsparkdev/tsconfig": "npm:0.0.1" "@types/crypto-js": "npm:^4.1.1" @@ -4016,9 +4016,9 @@ __metadata: version: 0.0.0-use.local resolution: "@lightsparkdev/nodejs-scripts@workspace:apps/examples/nodejs-scripts" dependencies: - "@lightsparkdev/core": "npm:1.5.2" + "@lightsparkdev/core": "npm:1.5.1" "@lightsparkdev/eslint-config": "npm:*" - "@lightsparkdev/lightspark-sdk": "npm:1.9.19" + "@lightsparkdev/lightspark-sdk": "npm:1.9.18" "@lightsparkdev/tsconfig": "npm:0.0.1" "@types/jest": "npm:^29.5.3" "@types/node": "npm:^20.2.5" @@ -4045,10 +4045,10 @@ __metadata: "@emotion/react": "npm:^11.11.0" "@emotion/styled": "npm:^11.11.0" "@lightsparkdev/eslint-config": "npm:*" - "@lightsparkdev/lightspark-sdk": "npm:1.9.19" + "@lightsparkdev/lightspark-sdk": "npm:1.9.18" "@lightsparkdev/oauth": "npm:*" "@lightsparkdev/tsconfig": "npm:0.0.1" - "@lightsparkdev/ui": "npm:1.1.20" + "@lightsparkdev/ui": "npm:1.1.19" "@types/jest": "npm:^29.5.3" "@types/node": "npm:^20.2.5" "@types/react": "npm:^18.2.12" @@ -4073,7 +4073,7 @@ __metadata: resolution: "@lightsparkdev/oauth@workspace:packages/oauth" dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" - "@lightsparkdev/core": "npm:1.5.2" + "@lightsparkdev/core": "npm:1.5.1" "@lightsparkdev/eslint-config": "npm:*" "@lightsparkdev/tsconfig": "npm:0.0.1" "@openid/appauth": "npm:^1.3.1" @@ -4148,8 +4148,8 @@ __metadata: version: 0.0.0-use.local resolution: "@lightsparkdev/remote-signing-server@workspace:apps/examples/remote-signing-server" dependencies: - "@lightsparkdev/core": "npm:1.5.2" - "@lightsparkdev/lightspark-sdk": "npm:1.9.19" + "@lightsparkdev/core": "npm:1.5.1" + "@lightsparkdev/lightspark-sdk": "npm:1.9.18" "@lightsparkdev/tsconfig": "npm:0.0.1" "@types/jest": "npm:^29.5.3" "@types/node": "npm:^20.2.5" @@ -4195,10 +4195,10 @@ __metadata: "@emotion/jest": "npm:^11.13.0" "@emotion/react": "npm:^11.11.0" "@emotion/styled": "npm:^11.11.0" - "@lightsparkdev/core": "npm:1.5.2" + "@lightsparkdev/core": "npm:1.5.1" "@lightsparkdev/eslint-config": "npm:*" "@lightsparkdev/tsconfig": "npm:0.0.1" - "@lightsparkdev/ui": "npm:1.1.20" + "@lightsparkdev/ui": "npm:1.1.19" "@lightsparkdev/vite": "npm:*" "@testing-library/jest-dom": "npm:^6.1.2" "@types/jest": "npm:^29.5.3" @@ -4223,7 +4223,7 @@ __metadata: languageName: unknown linkType: soft -"@lightsparkdev/ui@npm:1.1.20, @lightsparkdev/ui@workspace:packages/ui": +"@lightsparkdev/ui@npm:1.1.19, @lightsparkdev/ui@workspace:packages/ui": version: 0.0.0-use.local resolution: "@lightsparkdev/ui@workspace:packages/ui" dependencies: @@ -4232,7 +4232,7 @@ __metadata: "@emotion/css": "npm:^11.11.0" "@emotion/react": "npm:^11.11.0" "@emotion/styled": "npm:^11.11.0" - "@lightsparkdev/core": "npm:1.5.2" + "@lightsparkdev/core": "npm:1.5.1" "@lightsparkdev/eslint-config": "npm:*" "@lightsparkdev/tsconfig": "npm:0.0.1" "@microsoft/api-extractor": "npm:^7.47.9" @@ -4294,9 +4294,9 @@ __metadata: resolution: "@lightsparkdev/uma-vasp-cli@workspace:apps/examples/uma-vasp-cli" dependencies: "@inquirer/prompts": "npm:^1.1.3" - "@lightsparkdev/core": "npm:1.5.2" + "@lightsparkdev/core": "npm:1.5.1" "@lightsparkdev/eslint-config": "npm:*" - "@lightsparkdev/lightspark-sdk": "npm:1.9.19" + "@lightsparkdev/lightspark-sdk": "npm:1.9.18" "@lightsparkdev/tsconfig": "npm:0.0.1" "@types/chalk": "npm:^2.2.0" "@types/node": "npm:^20.2.5" @@ -4320,8 +4320,8 @@ __metadata: version: 0.0.0-use.local resolution: "@lightsparkdev/uma-vasp@workspace:apps/examples/uma-vasp" dependencies: - "@lightsparkdev/core": "npm:1.5.2" - "@lightsparkdev/lightspark-sdk": "npm:1.9.19" + "@lightsparkdev/core": "npm:1.5.1" + "@lightsparkdev/lightspark-sdk": "npm:1.9.18" "@lightsparkdev/tsconfig": "npm:0.0.1" "@types/body-parser": "npm:^1.19.5" "@types/express": "npm:^4.17.21" From 6f9cae0128ea4dd7ac2347de9902cb6620c03af6 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 1 May 2026 12:54:35 -0700 Subject: [PATCH 06/56] [grid] Example app to test wallet module (#26717) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Reason A standalone browser-based example app is needed to demonstrate and manually exercise the full Grid Global Accounts API lifecycle, including credential creation, verification, session management, and wallet operations across all three supported authentication types (EMAIL_OTP, OAUTH, and PASSKEY). ## Overview Adds a new Vite + TypeScript single-page example app at `js/apps/examples/grid-global-accounts-example-app` that covers: - **Platform auth**: API client ID/secret input with sandbox and production mode selection. Sandbox uses magic string constants (`sandbox-valid-signature`, `000000`, `sandbox-valid-oidc-token`, `sandbox-valid-passkey-signature`). Production mode generates a client-side P-256 keypair, HPKE-decrypts the `encryptedSessionSigningKey` returned by Verify using `@turnkey/crypto`, and stamps `payloadToSign` values via `@turnkey/api-key-stamper`. - **Customer setup**: Create customer and fetch internal account balance, with auto-propagation of account/credential/session IDs into a shared wallet context used across all tabs. - **Per-type lifecycle tabs** for EMAIL_OTP, OAUTH, and PASSKEY, each covering: wallet creation, credential verification → session, rechallenge, and two-step signed-retry flows for adding a second credential, deleting a credential, deleting a session, and exporting the wallet. - **External account creation** for both `SPARK_WALLET` and `USD_ACCOUNT` types, quote creation with `payloadToSign` extraction, payload signing (sandbox magic or real Turnkey stamp), and quote execution. - A Vite dev server proxy that rewrites `/api` requests to `https://api.lightspark.com/grid/2025-10-13`. The app is registered on port `3106` in `settings.json`. ## Test Plan Run `yarn dev` from the app directory and manually exercise each tab's lifecycle against the sandbox environment using the pre-filled magic values. Verify that signed-retry flows correctly populate `requestId` from step 1 and forward it with `Grid-Wallet-Signature` in step 2. For production mode, generate a P-256 key, run a Verify step, then use "Sign payload" before executing a quote to confirm HPKE decryption and Turnkey stamping work end-to-end. GitOrigin-RevId: fe887c117e70114303ebf6de67b9449fc8059c7b --- .../index.html | 876 ++++++++++++++ .../package.json | 19 + .../src/main.ts | 1024 +++++++++++++++++ .../tsconfig.json | 15 + .../vite.config.ts | 21 + apps/examples/settings.json | 3 + 6 files changed, 1958 insertions(+) create mode 100644 apps/examples/grid-global-accounts-example-app/index.html create mode 100644 apps/examples/grid-global-accounts-example-app/package.json create mode 100644 apps/examples/grid-global-accounts-example-app/src/main.ts create mode 100644 apps/examples/grid-global-accounts-example-app/tsconfig.json create mode 100644 apps/examples/grid-global-accounts-example-app/vite.config.ts diff --git a/apps/examples/grid-global-accounts-example-app/index.html b/apps/examples/grid-global-accounts-example-app/index.html new file mode 100644 index 000000000..e5b037329 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/index.html @@ -0,0 +1,876 @@ + + + + + + Grid Global Accounts - Example App + + + +

Grid Global Accounts - Example App

+

+ Signed-retry flows show the requestId / + payloadToSign from step 1 so you can inspect them before + step 2 forwards with + Grid-Wallet-Signature: sandbox-valid-signature. +

+ + + +
+

Platform Auth

+
+
+ + +
+
+ + +
+
+ + +

+ Sandbox uses server-side magic strings + (sandbox-valid-signature, + 000000, sandbox-valid-oidc-token, + sandbox-valid-passkey-signature). Production persists the + client P-256 keypair + the encrypted session signing key from Verify, + then HPKE-decrypts via @turnkey/crypto and stamps real + payloadToSign values via + @turnkey/api-key-stamper. +

+
+ +
+

Customer Setup

+
+
+ + +
+
+ + +
+
+ + + +
+ +
+ + + +
+
+
+ + + +
+

Wallet Context

+

+ Internal account id flows into every tab. Credential + session ids are + auto-filled as you run steps. +

+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+ + + +
+ + + +
+
+

EMAIL_OTP lifecycle

+ +
+

Create wallet

+

+ First-time create; email resolved from customer record. +

+ +
+
+ +
+

Verify → session

+ + + + + + +
+
+ +
+

+ Rechallenge (re-issue OTP) +

+

Uses Credential ID from Wallet Context.

+ +
+
+ +
+

+ Add second EMAIL_OTP via signed retry +

+

+ Rejects because one EMAIL_OTP already attached — step 1 exercises + the reject path. Remove the first EMAIL_OTP to test the full add + flow. +

+ +
+ + + +
+
+ +
+

+ Delete credential via signed retry +

+

+ No sandbox gate yet; step 1 succeeds, step 2 may fail against real + Turnkey. +

+ +
+ + + +
+
+ +
+

+ Delete session via signed retry +

+ +
+ + + +
+
+ +
+

+ Wallet export via signed retry +

+ +
+ + + +
+
+
+
+ + + +
+
+

OAUTH lifecycle

+ +
+

Create wallet

+ + + +
+
+ +
+

Verify → session

+ + + + + + +
+
+ +
+

Rechallenge

+

+ OAUTH rechallenge is a no-op — just returns AuthMethod. +

+ +
+
+ +
+

+ Add additional OAUTH via signed retry +

+ + + +
+ + + +
+
+ +
+

+ Delete credential via signed retry +

+ +
+ + + +
+
+ +
+

+ Delete session via signed retry +

+ +
+ + + +
+
+ +
+

+ Wallet export via signed retry +

+ +
+ + + +
+
+
+
+ + + +
+
+

PASSKEY lifecycle

+ +
+

Create wallet

+ + + + + + + + + + + +
+
+ +
+

Session challenge

+

+ PR 4 flow: /challenge returns + challenge = sha256(CREATE_READ_WRITE_SESSION body) + + requestId. Client signs the challenge via WebAuthn. +

+ + + + +
+
+ +
+

Verify → session

+ + + + + + + + + +
+
+ +
+

+ Add additional PASSKEY via signed retry +

+ + + +
+ + + +
+
+ +
+

+ Delete credential via signed retry +

+ +
+ + + +
+
+ +
+

+ Delete session via signed retry +

+ +
+ + + +
+
+ +
+

+ Wallet export via signed retry +

+ +
+ + + +
+
+
+
+ + + +
+

List credentials / sessions

+ + +
+
+ + + +
+

External Account

+ + +
+ + +
+ + +
+
+ +
+

Quote + Execute

+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + + + + + + + +
+
+
+ + + +
+

Response Log

+
+
+ + + + diff --git a/apps/examples/grid-global-accounts-example-app/package.json b/apps/examples/grid-global-accounts-example-app/package.json new file mode 100644 index 000000000..3ffe1a730 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/package.json @@ -0,0 +1,19 @@ +{ + "name": "@lightsparkdev/grid-global-accounts-example-app", + "private": true, + "version": "0.0.1", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "start": "vite", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "^5.6.2", + "vite": "^8.0.3" + }, + "dependencies": { + "@turnkey/api-key-stamper": "^0.6.5", + "@turnkey/crypto": "^2.8.14" + } +} diff --git a/apps/examples/grid-global-accounts-example-app/src/main.ts b/apps/examples/grid-global-accounts-example-app/src/main.ts new file mode 100644 index 000000000..3143a736d --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/main.ts @@ -0,0 +1,1024 @@ +// Grid Global Accounts — Example App +// +// Tabbed lifecycle per credential type (EMAIL_OTP / OAUTH / PASSKEY) + +// shared customer / external account / quote / execute sections. +// Signed-retry flows are two-step: issue (returns 202 challenge) then retry +// (forwards with `Grid-Wallet-Signature: sandbox-valid-signature`). + +import { decryptCredentialBundle, generateP256KeyPair, getPublicKey } from "@turnkey/crypto"; +import { signWithApiKey } from "@turnkey/api-key-stamper"; + +type Mode = "sandbox" | "production"; +type CredType = "email_otp" | "oauth" | "passkey"; + +const SANDBOX_SIG = "sandbox-valid-signature"; +// All requests proxy through Vite at `/api` and forward to prod. +// Credentials are entered manually in the UI — never embedded. +const API_BASE = "/api"; + +// Turnkey API stamp scheme — must match what `@turnkey/api-key-stamper` emits. +const TURNKEY_STAMP_SCHEME = "SIGNATURE_SCHEME_TK_API_P256"; + +// ----- Production-mode key state ----- +// +// Generated client-side at the first call to `generateClientKeyPair`. The +// uncompressed public key (130 hex chars, 0x04-prefixed) goes to Grid as +// `clientPublicKey` on Verify; the private key is held here and used to +// HPKE-decrypt the `encryptedSessionSigningKey` Grid hands back, yielding +// the Turnkey API session keypair we then stamp `payloadToSign` with. +// +// In sandbox mode the bundle is shape-valid but undecryptable — sandbox +// flows skip this entire path and use the magic signature constants. + +interface ClientKeyPair { + privateKey: string; // hex + publicKey: string; // hex, compressed + publicKeyUncompressed: string; // hex, 130 chars (0x04 prefix) +} + +interface SessionKeys { + apiPublicKey: string; // hex, compressed P-256 + apiPrivateKey: string; // hex +} + +let clientKeyPair: ClientKeyPair | null = null; +let lastEncryptedSessionSigningKey: string | null = null; +let cachedSessionKeys: SessionKeys | null = null; + +function generateClientKeyPair(): ClientKeyPair { + const kp = generateP256KeyPair(); + clientKeyPair = { + privateKey: kp.privateKey, + publicKey: kp.publicKey, + publicKeyUncompressed: kp.publicKeyUncompressed, + }; + // Re-using the keypair across credential types means a Verify by any + // type cycles fresh session bundles bound to the same client key — + // simpler than tracking one keypair per type for the test app. + cachedSessionKeys = null; + lastEncryptedSessionSigningKey = null; + return clientKeyPair; +} + +function rememberEncryptedSessionSigningKey(value: unknown): void { + if (typeof value === "string" && value) { + lastEncryptedSessionSigningKey = value; + cachedSessionKeys = null; + } +} + +function decryptSessionKeysOrThrow(): SessionKeys { + if (cachedSessionKeys) return cachedSessionKeys; + if (!clientKeyPair) + throw new Error("No client keypair — run a Verify in production mode first."); + if (!lastEncryptedSessionSigningKey) + throw new Error( + "No encryptedSessionSigningKey — run a Verify in production mode first.", + ); + const apiPrivateKey = decryptCredentialBundle( + lastEncryptedSessionSigningKey, + clientKeyPair.privateKey, + ); + const apiPublicKeyBytes = getPublicKey(apiPrivateKey, /*isCompressed*/ true); + const apiPublicKey = Array.from(apiPublicKeyBytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + cachedSessionKeys = { apiPublicKey, apiPrivateKey }; + return cachedSessionKeys; +} + +async function turnkeyStamp(payload: string): Promise { + const { apiPublicKey, apiPrivateKey } = decryptSessionKeysOrThrow(); + // `signWithApiKey` returns the hex DER signature; the X-Stamp header + // value is base64url(JSON({publicKey, scheme, signature})) with that + // hex signature embedded as-is. Mirrors what `@turnkey/api-key-stamper` + // produces internally; replicated here so we can fill the field on the + // test UI rather than going through the stamper's `stamp(payload)` shape + // (which returns `{stampHeaderName, stampHeaderValue}`). + const signature = await signWithApiKey({ + content: payload, + publicKey: apiPublicKey, + privateKey: apiPrivateKey, + }); + const stamp = { + publicKey: apiPublicKey, + scheme: TURNKEY_STAMP_SCHEME, + signature, + }; + const json = JSON.stringify(stamp); + // base64url(json) — no padding. + return btoa(json).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +// ----- DOM helpers ----- + +function el(id: string): T { + const found = document.getElementById(id); + if (!found) throw new Error(`Missing element #${id}`); + return found as T; +} + +function maybeEl(id: string): T | null { + return document.getElementById(id) as T | null; +} + +// ----- Auth / HTTP / Mode ----- + +const authClientId = el("auth-client-id"); +const authClientSecret = el("auth-client-secret"); +const modeSelect = el("mode-select"); + +function getMode(): Mode { + return modeSelect.value === "production" ? "production" : "sandbox"; +} + +function getAuthHeader(): string { + return "Basic " + btoa(`${authClientId.value.trim()}:${authClientSecret.value.trim()}`); +} + +async function apiPost( + path: string, + body: Record | undefined, + extraHeaders: Record = {}, +): Promise<{ status: number; data: unknown }> { + const res = await fetch(API_BASE + path, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: getAuthHeader(), + ...extraHeaders, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const raw = await res.text(); + const data = raw ? JSON.parse(raw) : null; + if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`); + return { status: res.status, data }; +} + +async function apiDelete( + path: string, + extraHeaders: Record = {}, +): Promise<{ status: number; data: unknown }> { + const res = await fetch(API_BASE + path, { + method: "DELETE", + headers: { + Authorization: getAuthHeader(), + ...extraHeaders, + }, + }); + const raw = await res.text(); + const data = raw ? JSON.parse(raw) : null; + if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`); + return { status: res.status, data }; +} + +async function apiGet(path: string): Promise { + const res = await fetch(API_BASE + path, { + headers: { Authorization: getAuthHeader() }, + }); + const raw = await res.text(); + const data = raw ? JSON.parse(raw) : null; + if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`); + return data; +} + +// ----- Logging ----- + +const logContainer = el("log"); + +function timestamp(): string { + return new Date().toISOString().replace("T", " ").slice(0, 19); +} + +function addLog(label: string, data: unknown): void { + const entry = document.createElement("div"); + entry.className = "log-entry"; + const ts = document.createElement("span"); + ts.className = "log-ts"; + ts.textContent = timestamp(); + const lbl = document.createElement("span"); + lbl.className = "log-label"; + lbl.textContent = `[${label}]`; + const body = document.createTextNode(`\n${JSON.stringify(data, null, 2)}`); + entry.append(ts, " ", lbl, body); + logContainer.prepend(entry); +} + +function showStatus(el: HTMLDivElement, ok: boolean, text: string): void { + el.className = `status ${ok ? "ok" : "err"}`; + el.textContent = text; +} + +// ----- Context (cross-tab) ----- + +const ctxAccountId = el("ctx-account-id"); +const ctxCredentialId = el("ctx-credential-id"); +const ctxSessionId = el("ctx-session-id"); + +function setCtxAccount(id: string): void { + if (!ctxAccountId.value) ctxAccountId.value = id; +} +function setCtxCredential(id: string): void { + ctxCredentialId.value = id; +} +function setCtxSession(id: string): void { + ctxSessionId.value = id; +} + +// ----- Generic click wrapper ----- + +function bindClick( + btnId: string, + statusId: string, + label: string, + runningText: string, + handler: () => Promise, +): void { + const btn = maybeEl(btnId); + const statusEl = maybeEl(statusId); + if (!btn || !statusEl) { + console.warn(`bindClick: missing btn=${btnId} or status=${statusId}`); + return; + } + btn.addEventListener("click", async () => { + btn.disabled = true; + showStatus(statusEl, true, runningText); + try { + const responseText = await handler(); + showStatus(statusEl, true, responseText); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + addLog(`${label} Error`, { error: msg }); + showStatus(statusEl, false, msg); + } finally { + btn.disabled = false; + } + }); +} + +// ----- Key generation helper ----- +// +// All "Generate P-256 Key" buttons share the same module-level +// `clientKeyPair` so a session decrypted under one keypair stays valid +// across tabs. The button writes the uncompressed public key into the +// target field — that's what Grid's `clientPublicKey` API expects. + +function wireGenKeyButton(btnId: string, targetInputId: string): void { + const btn = maybeEl(btnId); + const target = maybeEl(targetInputId); + if (!btn || !target) return; + btn.addEventListener("click", () => { + btn.disabled = true; + try { + const kp = generateClientKeyPair(); + target.value = kp.publicKeyUncompressed; + addLog("Key Generated", { + publicKeyUncompressed: kp.publicKeyUncompressed, + }); + } catch (err) { + addLog("Key Generation Error", { error: String(err) }); + } finally { + btn.disabled = false; + } + }); +} + +// ----- Tab switching ----- + +for (const tabBtn of document.querySelectorAll(".tab")) { + tabBtn.addEventListener("click", () => { + const name = tabBtn.dataset.tab!; + document + .querySelectorAll(".tab") + .forEach((b) => b.classList.toggle("active", b.dataset.tab === name)); + document + .querySelectorAll(".tab-panel") + .forEach((p) => p.classList.toggle("active", p.dataset.panel === name)); + }); +} + +// ========================================================== +// Shared setup: Create customer + Fetch balance +// ========================================================== + +const createPlatformCustomerId = el("create-platform-customer-id"); +const createCustomerName = el("create-customer-name"); +const createCustomerEmail = el("create-customer-email"); +const balanceCustomerId = el("balance-customer-id"); + +bindClick( + "btn-create-customer", + "create-customer-status", + "Create Customer", + "Creating customer...", + async () => { + const platformCustomerId = + createPlatformCustomerId.value.trim() || `test-${Date.now()}`; + const fullName = createCustomerName.value.trim() || "Test User"; + const email = createCustomerEmail.value.trim(); + const body: Record = { + customerType: "BUSINESS", + platformCustomerId, + region: "US", + currencies: ["USDB"], + businessInfo: { legalName: fullName }, + }; + if (email) body.email = email; + const { data: customer } = await apiPost("/customers", body); + addLog("Create Customer", customer); + const customerId = (customer as Record).id as string; + if (!balanceCustomerId.value) balanceCustomerId.value = customerId; + const accounts = (await apiGet( + `/customers/internal-accounts?customerId=${customerId}¤cy=USDB`, + )) as { data: Array<{ id: string }> }; + addLog("Internal Accounts", accounts); + if (accounts.data && accounts.data.length > 0) { + setCtxAccount(accounts.data[0].id); + return `Customer: ${customerId}\nAccount: ${accounts.data[0].id}`; + } + return `Customer: ${customerId}\nNo USDB account found`; + }, +); + +bindClick( + "btn-fetch-balance", + "balance-status", + "Fetch Balance", + "Fetching balance...", + async () => { + const customerId = balanceCustomerId.value.trim(); + if (!customerId) throw new Error("Customer ID is required."); + const data = (await apiGet( + `/customers/internal-accounts?customerId=${encodeURIComponent(customerId)}`, + )) as { data: Array> }; + addLog("Fetch Balance", data); + return JSON.stringify( + data.data?.map((a) => ({ id: a.id, currency: a.currency, balance: a.balance })) ?? + [], + null, + 2, + ); + }, +); + +// ========================================================== +// Per-type lifecycle +// ========================================================== + +function requireAccountId(): string { + const id = ctxAccountId.value.trim(); + if (!id) + throw new Error("Internal Account ID is required — run Create Customer first."); + return id; +} + +function requireCredentialId(): string { + const id = ctxCredentialId.value.trim(); + if (!id) throw new Error("Credential ID is required — run Create for this type first."); + return id; +} + +function requireSessionId(): string { + const id = ctxSessionId.value.trim(); + if (!id) throw new Error("Session ID is required — run Verify for this type first."); + return id; +} + +// ----- EMAIL_OTP ----- + +bindClick( + "btn-email_otp-create", + "email_otp-create-status", + "EMAIL_OTP Create", + "Registering EMAIL_OTP credential...", + async () => { + const { data } = await apiPost("/auth/credentials", { + type: "EMAIL_OTP", + accountId: requireAccountId(), + }); + addLog("EMAIL_OTP Create", data); + const d = data as Record; + if (d.id) setCtxCredential(d.id as string); + return JSON.stringify(data, null, 2); + }, +); + +wireGenKeyButton("btn-email_otp-verify-genkey", "email_otp-verify-pubkey"); +bindClick( + "btn-email_otp-verify", + "email_otp-verify-status", + "EMAIL_OTP Verify", + "Verifying...", + async () => { + const credId = requireCredentialId(); + const otp = el("email_otp-verify-code").value.trim(); + const pubkey = el("email_otp-verify-pubkey").value.trim(); + if (!otp || !pubkey) throw new Error("OTP code and public key are required."); + const { data } = await apiPost( + `/auth/credentials/${encodeURIComponent(credId)}/verify`, + { type: "EMAIL_OTP", otp, clientPublicKey: pubkey }, + ); + addLog("EMAIL_OTP Verify", data); + const d = data as Record; + if (d.id) setCtxSession(d.id as string); + rememberEncryptedSessionSigningKey(d.encryptedSessionSigningKey); + return JSON.stringify(data, null, 2); + }, +); + +bindClick( + "btn-email_otp-rechallenge", + "email_otp-rechallenge-status", + "EMAIL_OTP Rechallenge", + "Re-issuing OTP...", + async () => { + const credId = requireCredentialId(); + const { data } = await apiPost( + `/auth/credentials/${encodeURIComponent(credId)}/challenge`, + {}, + ); + addLog("EMAIL_OTP Rechallenge", data); + return JSON.stringify(data, null, 2); + }, +); + +const emailOtpAddRequestId = el("email_otp-add-request-id"); +bindClick( + "btn-email_otp-add-issue", + "email_otp-add-issue-status", + "EMAIL_OTP Add (issue)", + "Issuing add challenge...", + async () => { + const { data } = await apiPost("/auth/credentials", { + type: "EMAIL_OTP", + accountId: requireAccountId(), + }); + addLog("EMAIL_OTP Add (issue)", data); + const d = data as Record; + if (d.requestId) emailOtpAddRequestId.value = d.requestId as string; + return JSON.stringify(data, null, 2); + }, +); +bindClick( + "btn-email_otp-add-retry", + "email_otp-add-retry-status", + "EMAIL_OTP Add (retry)", + "Forwarding signed retry...", + async () => { + const requestId = emailOtpAddRequestId.value.trim(); + if (!requestId) throw new Error("Request-Id is required — run step 1 first."); + const { data } = await apiPost( + "/auth/credentials", + { type: "EMAIL_OTP", accountId: requireAccountId() }, + { "Grid-Wallet-Signature": SANDBOX_SIG, "Request-Id": requestId }, + ); + addLog("EMAIL_OTP Add (retry)", data); + return JSON.stringify(data, null, 2); + }, +); + +// ----- OAUTH ----- + +bindClick( + "btn-oauth-create", + "oauth-create-status", + "OAUTH Create", + "Creating OAUTH wallet...", + async () => { + const oidc = el("oauth-create-oidc").value.trim(); + if (!oidc) throw new Error("OIDC token is required."); + const { data } = await apiPost("/auth/credentials", { + type: "OAUTH", + accountId: requireAccountId(), + oidcToken: oidc, + }); + addLog("OAUTH Create", data); + const d = data as Record; + if (d.id) setCtxCredential(d.id as string); + return JSON.stringify(data, null, 2); + }, +); + +wireGenKeyButton("btn-oauth-verify-genkey", "oauth-verify-pubkey"); +bindClick( + "btn-oauth-verify", + "oauth-verify-status", + "OAUTH Verify", + "Verifying...", + async () => { + const credId = requireCredentialId(); + const oidc = el("oauth-verify-oidc").value.trim(); + const pubkey = el("oauth-verify-pubkey").value.trim(); + if (!oidc || !pubkey) throw new Error("OIDC token and public key are required."); + const { data } = await apiPost( + `/auth/credentials/${encodeURIComponent(credId)}/verify`, + { type: "OAUTH", oidcToken: oidc, clientPublicKey: pubkey }, + ); + addLog("OAUTH Verify", data); + const d = data as Record; + if (d.id) setCtxSession(d.id as string); + rememberEncryptedSessionSigningKey(d.encryptedSessionSigningKey); + return JSON.stringify(data, null, 2); + }, +); + +bindClick( + "btn-oauth-rechallenge", + "oauth-rechallenge-status", + "OAUTH Rechallenge", + "Running no-op rechallenge...", + async () => { + const credId = requireCredentialId(); + const { data } = await apiPost( + `/auth/credentials/${encodeURIComponent(credId)}/challenge`, + {}, + ); + addLog("OAUTH Rechallenge", data); + return JSON.stringify(data, null, 2); + }, +); + +const oauthAddRequestId = el("oauth-add-request-id"); +bindClick( + "btn-oauth-add-issue", + "oauth-add-issue-status", + "OAUTH Add (issue)", + "Issuing add challenge...", + async () => { + const oidc = el("oauth-add-oidc").value.trim(); + if (!oidc) throw new Error("OIDC token is required."); + const { data } = await apiPost("/auth/credentials", { + type: "OAUTH", + accountId: requireAccountId(), + oidcToken: oidc, + }); + addLog("OAUTH Add (issue)", data); + const d = data as Record; + if (d.requestId) oauthAddRequestId.value = d.requestId as string; + return JSON.stringify(data, null, 2); + }, +); +bindClick( + "btn-oauth-add-retry", + "oauth-add-retry-status", + "OAUTH Add (retry)", + "Forwarding signed retry...", + async () => { + const requestId = oauthAddRequestId.value.trim(); + if (!requestId) throw new Error("Request-Id is required — run step 1 first."); + const oidc = el("oauth-add-oidc").value.trim(); + const { data } = await apiPost( + "/auth/credentials", + { type: "OAUTH", accountId: requireAccountId(), oidcToken: oidc }, + { "Grid-Wallet-Signature": SANDBOX_SIG, "Request-Id": requestId }, + ); + addLog("OAUTH Add (retry)", data); + return JSON.stringify(data, null, 2); + }, +); + +// ----- PASSKEY ----- + +bindClick( + "btn-passkey-create", + "passkey-create-status", + "PASSKEY Create", + "Creating PASSKEY wallet...", + async () => { + const body = { + type: "PASSKEY", + accountId: requireAccountId(), + nickname: el("passkey-create-nickname").value.trim(), + challenge: el("passkey-create-challenge").value.trim(), + attestation: { + credentialId: el("passkey-create-cred-id-raw").value.trim(), + clientDataJson: el("passkey-create-client-data-json").value.trim(), + attestationObject: el("passkey-create-attestation-object").value.trim(), + }, + }; + const { data } = await apiPost("/auth/credentials", body); + addLog("PASSKEY Create", data); + const d = data as Record; + if (d.id) setCtxCredential(d.id as string); + return JSON.stringify(data, null, 2); + }, +); + +wireGenKeyButton("btn-passkey-challenge-genkey", "passkey-challenge-pubkey"); +const passkeyVerifyRequestId = el("passkey-verify-request-id"); +bindClick( + "btn-passkey-challenge", + "passkey-challenge-status", + "PASSKEY Challenge", + "Issuing session challenge...", + async () => { + const credId = requireCredentialId(); + const pubkey = el("passkey-challenge-pubkey").value.trim(); + if (!pubkey) throw new Error("Client public key is required — generate one first."); + const { data } = await apiPost( + `/auth/credentials/${encodeURIComponent(credId)}/challenge`, + { clientPublicKey: pubkey }, + ); + addLog("PASSKEY Challenge", data); + const d = data as Record; + if (d.requestId) passkeyVerifyRequestId.value = d.requestId as string; + return JSON.stringify(data, null, 2); + }, +); + +bindClick( + "btn-passkey-verify", + "passkey-verify-status", + "PASSKEY Verify", + "Verifying assertion...", + async () => { + const credId = requireCredentialId(); + const requestId = passkeyVerifyRequestId.value.trim(); + const body = { + type: "PASSKEY", + clientPublicKey: el("passkey-challenge-pubkey").value.trim(), + assertion: { + credentialId: el("passkey-create-cred-id-raw").value.trim(), + clientDataJson: el("passkey-verify-client-data-json").value.trim(), + authenticatorData: el("passkey-verify-auth-data").value.trim(), + signature: el("passkey-verify-signature").value.trim(), + }, + }; + const headers: Record = {}; + if (requestId) headers["Request-Id"] = requestId; + const { data } = await apiPost( + `/auth/credentials/${encodeURIComponent(credId)}/verify`, + body, + headers, + ); + addLog("PASSKEY Verify", data); + const d = data as Record; + if (d.id) setCtxSession(d.id as string); + rememberEncryptedSessionSigningKey(d.encryptedSessionSigningKey); + return JSON.stringify(data, null, 2); + }, +); + +const passkeyAddRequestId = el("passkey-add-request-id"); +function buildPasskeyAddBody(): Record { + return { + type: "PASSKEY", + accountId: requireAccountId(), + nickname: el("passkey-add-nickname").value.trim(), + challenge: el("passkey-create-challenge").value.trim(), + attestation: { + credentialId: el("passkey-create-cred-id-raw").value.trim(), + clientDataJson: el("passkey-create-client-data-json").value.trim(), + attestationObject: el("passkey-create-attestation-object").value.trim(), + }, + }; +} +bindClick( + "btn-passkey-add-issue", + "passkey-add-issue-status", + "PASSKEY Add (issue)", + "Issuing add challenge...", + async () => { + const { data } = await apiPost("/auth/credentials", buildPasskeyAddBody()); + addLog("PASSKEY Add (issue)", data); + const d = data as Record; + if (d.requestId) passkeyAddRequestId.value = d.requestId as string; + return JSON.stringify(data, null, 2); + }, +); +bindClick( + "btn-passkey-add-retry", + "passkey-add-retry-status", + "PASSKEY Add (retry)", + "Forwarding signed retry...", + async () => { + const requestId = passkeyAddRequestId.value.trim(); + if (!requestId) throw new Error("Request-Id is required — run step 1 first."); + const { data } = await apiPost( + "/auth/credentials", + buildPasskeyAddBody(), + { "Grid-Wallet-Signature": SANDBOX_SIG, "Request-Id": requestId }, + ); + addLog("PASSKEY Add (retry)", data); + return JSON.stringify(data, null, 2); + }, +); + +// ========================================================== +// Shared signed-retry wiring per tab: delete credential / session / export +// Endpoints identical for all tabs — inputs come from the shared ctx, the +// per-tab buttons just visually group each flow under the relevant tab. +// ========================================================== + +function wireDeleteCredentialButtons(type: CredType): void { + const reqInput = el(`${type}-del-cred-request-id`); + bindClick( + `btn-${type}-del-cred-issue`, + `${type}-del-cred-issue-status`, + "Delete Credential (issue)", + "Issuing delete challenge...", + async () => { + const credId = requireCredentialId(); + const { data } = await apiDelete( + `/auth/credentials/${encodeURIComponent(credId)}`, + ); + addLog("Delete Credential (issue)", data); + const d = data as Record; + if (d.requestId) reqInput.value = d.requestId as string; + return JSON.stringify(data, null, 2); + }, + ); + bindClick( + `btn-${type}-del-cred-retry`, + `${type}-del-cred-retry-status`, + "Delete Credential (retry)", + "Forwarding signed retry...", + async () => { + const credId = requireCredentialId(); + const requestId = reqInput.value.trim(); + if (!requestId) throw new Error("Request-Id is required — run step 1 first."); + const { data } = await apiDelete( + `/auth/credentials/${encodeURIComponent(credId)}`, + { "Grid-Wallet-Signature": SANDBOX_SIG, "Request-Id": requestId }, + ); + addLog("Delete Credential (retry)", data); + return JSON.stringify(data, null, 2); + }, + ); +} + +function wireDeleteSessionButtons(type: CredType): void { + const reqInput = el(`${type}-del-session-request-id`); + bindClick( + `btn-${type}-del-session-issue`, + `${type}-del-session-issue-status`, + "Delete Session (issue)", + "Issuing delete challenge...", + async () => { + const sid = requireSessionId(); + const { data } = await apiDelete( + `/auth/sessions/${encodeURIComponent(sid)}`, + ); + addLog("Delete Session (issue)", data); + const d = data as Record; + if (d.requestId) reqInput.value = d.requestId as string; + return JSON.stringify(data, null, 2); + }, + ); + bindClick( + `btn-${type}-del-session-retry`, + `${type}-del-session-retry-status`, + "Delete Session (retry)", + "Forwarding signed retry...", + async () => { + const sid = requireSessionId(); + const requestId = reqInput.value.trim(); + if (!requestId) throw new Error("Request-Id is required — run step 1 first."); + const { data } = await apiDelete( + `/auth/sessions/${encodeURIComponent(sid)}`, + { "Grid-Wallet-Signature": SANDBOX_SIG, "Request-Id": requestId }, + ); + addLog("Delete Session (retry)", data); + return JSON.stringify(data, null, 2); + }, + ); +} + +function wireExportButtons(type: CredType): void { + const reqInput = el(`${type}-export-request-id`); + bindClick( + `btn-${type}-export-issue`, + `${type}-export-issue-status`, + "Wallet Export (issue)", + "Issuing export challenge...", + async () => { + const accountId = requireAccountId(); + const { data } = await apiPost( + `/internal-accounts/${encodeURIComponent(accountId)}/export`, + {}, + ); + addLog("Wallet Export (issue)", data); + const d = data as Record; + if (d.requestId) reqInput.value = d.requestId as string; + return JSON.stringify(data, null, 2); + }, + ); + bindClick( + `btn-${type}-export-retry`, + `${type}-export-retry-status`, + "Wallet Export (retry)", + "Forwarding signed retry...", + async () => { + const accountId = requireAccountId(); + const requestId = reqInput.value.trim(); + if (!requestId) throw new Error("Request-Id is required — run step 1 first."); + const { data } = await apiPost( + `/internal-accounts/${encodeURIComponent(accountId)}/export`, + {}, + { "Grid-Wallet-Signature": SANDBOX_SIG, "Request-Id": requestId }, + ); + addLog("Wallet Export (retry)", data); + return JSON.stringify(data, null, 2); + }, + ); +} + +for (const type of ["email_otp", "oauth", "passkey"] as const) { + wireDeleteCredentialButtons(type); + wireDeleteSessionButtons(type); + wireExportButtons(type); +} + +// ========================================================== +// List credentials / sessions +// ========================================================== + +bindClick( + "btn-list-credentials", + "list-status", + "List Credentials", + "Listing...", + async () => { + const accountId = requireAccountId(); + const data = await apiGet( + `/auth/credentials?accountId=${encodeURIComponent(accountId)}`, + ); + addLog("List Credentials", data); + return JSON.stringify(data, null, 2); + }, +); + +bindClick( + "btn-list-sessions", + "list-status", + "List Sessions", + "Listing...", + async () => { + const accountId = requireAccountId(); + const data = await apiGet( + `/auth/sessions?accountId=${encodeURIComponent(accountId)}`, + ); + addLog("List Sessions", data); + return JSON.stringify(data, null, 2); + }, +); + +// ========================================================== +// External account + Quote + Execute +// ========================================================== + +const extAccountType = el("ext-account-type"); +const extSparkFields = el("ext-spark-fields"); +const extBankFields = el("ext-bank-fields"); +const quoteDestinationAccountId = el("quote-destination-account-id"); + +extAccountType.addEventListener("change", () => { + const isSpark = extAccountType.value === "SPARK_WALLET"; + extSparkFields.style.display = isSpark ? "" : "none"; + extBankFields.style.display = isSpark ? "none" : ""; +}); + +bindClick( + "btn-create-external-account", + "ext-account-status", + "Create External Account", + "Creating external account...", + async () => { + let body: Record; + if (extAccountType.value === "SPARK_WALLET") { + const address = el("ext-spark-address").value.trim(); + if (!address) throw new Error("Spark address is required."); + body = { + currency: "BTC", + accountInfo: { accountType: "SPARK_WALLET", address }, + }; + } else { + const accountNumber = el("ext-bank-account-number").value.trim(); + const routingNumber = el("ext-bank-routing-number").value.trim(); + const fullName = + el("ext-bank-beneficiary-name").value.trim() || "Sandbox Test User"; + if (!accountNumber || !routingNumber) + throw new Error("Account number and routing number are required."); + body = { + currency: "USD", + accountInfo: { + accountType: "USD_ACCOUNT", + countries: ["US"], + paymentRails: ["ACH", "WIRE", "RTP", "FEDNOW"], + accountNumber, + routingNumber, + beneficiary: { + beneficiaryType: "INDIVIDUAL", + fullName, + birthDate: "1990-01-15", + nationality: "US", + address: { + line1: "100 Test St", + city: "SF", + postalCode: "94102", + country: "US", + }, + }, + }, + }; + } + const { data } = await apiPost("/platform/external-accounts", body); + addLog("Create External Account", data); + const d = data as Record; + if (d.id) quoteDestinationAccountId.value = d.id as string; + return JSON.stringify(data, null, 2); + }, +); + +const executeQuoteId = el("execute-quote-id"); + +bindClick( + "btn-create-quote", + "quote-status", + "Create Quote", + "Creating quote...", + async () => { + const sourceAccountId = requireAccountId(); + const destinationAccountId = quoteDestinationAccountId.value.trim(); + const lockedAmount = Number(el("quote-locked-amount").value); + if (!destinationAccountId || !lockedAmount) + throw new Error("Destination external account and amount are required."); + const { data } = await apiPost("/quotes", { + source: { sourceType: "ACCOUNT", accountId: sourceAccountId }, + destination: { destinationType: "ACCOUNT", accountId: destinationAccountId }, + lockedCurrencySide: el("quote-locked-side").value, + lockedCurrencyAmount: lockedAmount, + }); + addLog("Create Quote", data); + const d = data as Record; + if (d.id) executeQuoteId.value = d.id as string; + // Extract `payloadToSign` from the EMBEDDED_WALLET payment instruction + // (second entry in the example response — find by accountType match). + const instructions = (d.paymentInstructions ?? []) as Array< + Record + >; + for (const inst of instructions) { + const info = inst.accountOrWalletInfo as Record | undefined; + if (info && info.accountType === "EMBEDDED_WALLET" && info.payloadToSign) { + executePayloadToSign.value = info.payloadToSign as string; + break; + } + } + // In sandbox mode, pre-fill the magic signature so the user can hit + // Execute immediately. In production mode, leave blank — the Sign + // payload button decrypts the session bundle and stamps it. + if (getMode() === "sandbox") { + executeSignature.value = SANDBOX_SIG; + } else { + executeSignature.value = ""; + } + return JSON.stringify(data, null, 2); + }, +); + +const executePayloadToSign = el("execute-payload-to-sign"); +const executeSignature = el("execute-signature"); + +bindClick( + "btn-sign-payload", + "execute-status", + "Sign Payload", + "Signing...", + async () => { + if (getMode() === "sandbox") { + executeSignature.value = SANDBOX_SIG; + return `Mode: sandbox — filled magic signature.`; + } + const payload = executePayloadToSign.value.trim(); + if (!payload) + throw new Error( + "payloadToSign is empty — run Create Quote first or paste it manually.", + ); + const stamp = await turnkeyStamp(payload); + executeSignature.value = stamp; + return `Stamped (${stamp.length} chars).`; + }, +); + +bindClick( + "btn-execute-quote", + "execute-status", + "Execute Quote", + "Executing quote...", + async () => { + const quoteId = executeQuoteId.value.trim(); + const signature = executeSignature.value.trim(); + if (!quoteId || !signature) + throw new Error("Quote ID and Grid-Wallet-Signature are required."); + const { data } = await apiPost( + `/quotes/${encodeURIComponent(quoteId)}/execute`, + {}, + { "Grid-Wallet-Signature": signature }, + ); + addLog("Execute Quote", data); + return JSON.stringify(data, null, 2); + }, +); + +console.log("Grid Global Accounts example app loaded."); diff --git a/apps/examples/grid-global-accounts-example-app/tsconfig.json b/apps/examples/grid-global-accounts-example-app/tsconfig.json new file mode 100644 index 000000000..4cdd777fe --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"] + }, + "include": ["src"] +} diff --git a/apps/examples/grid-global-accounts-example-app/vite.config.ts b/apps/examples/grid-global-accounts-example-app/vite.config.ts new file mode 100644 index 000000000..0513947cb --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vite"; +import settings from "../settings.json"; + +// Prod grid URL. The proxy strips the `/api` prefix and rewrites the path +// to the versioned API channel. Credentials are entered manually in the UI +// — never embedded here. +const PROD_GRID_URL = "https://api.lightspark.com"; + +export default defineConfig({ + server: { + port: settings.gridGlobalAccountsExampleApp.port, + proxy: { + "/api": { + target: PROD_GRID_URL, + changeOrigin: true, + secure: true, + rewrite: (path) => path.replace(/^\/api/, "/grid/2025-10-13"), + }, + }, + }, +}); diff --git a/apps/examples/settings.json b/apps/examples/settings.json index 5d1971d56..c2a5d4113 100644 --- a/apps/examples/settings.json +++ b/apps/examples/settings.json @@ -13,5 +13,8 @@ }, "uiTestApp": { "port": 3105 + }, + "gridGlobalAccountsExampleApp": { + "port": 3106 } } From 6f0e85a55683feadc31347ce7004f722ba7511d3 Mon Sep 17 00:00:00 2001 From: Lightspark Eng Date: Fri, 1 May 2026 20:02:59 +0000 Subject: [PATCH 07/56] CI update lock file for PR --- yarn.lock | 315 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 311 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index a9ab35766..5eb22e9e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3943,6 +3943,17 @@ __metadata: languageName: unknown linkType: soft +"@lightsparkdev/grid-global-accounts-example-app@workspace:apps/examples/grid-global-accounts-example-app": + version: 0.0.0-use.local + resolution: "@lightsparkdev/grid-global-accounts-example-app@workspace:apps/examples/grid-global-accounts-example-app" + dependencies: + "@turnkey/api-key-stamper": "npm:^0.6.5" + "@turnkey/crypto": "npm:^2.8.14" + typescript: "npm:^5.6.2" + vite: "npm:^8.0.3" + languageName: unknown + linkType: soft + "@lightsparkdev/lightspark-cli@workspace:packages/lightspark-cli": version: 0.0.0-use.local resolution: "@lightsparkdev/lightspark-cli@workspace:packages/lightspark-cli" @@ -4588,6 +4599,13 @@ __metadata: languageName: node linkType: hard +"@noble/ciphers@npm:1.3.0": + version: 1.3.0 + resolution: "@noble/ciphers@npm:1.3.0" + checksum: 10/051660051e3e9e2ca5fb9dece2885532b56b7e62946f89afa7284a0fb8bc02e2bd1c06554dba68162ff42d295b54026456084198610f63c296873b2f1cd7a586 + languageName: node + linkType: hard + "@noble/ciphers@npm:^0.3.0": version: 0.3.0 resolution: "@noble/ciphers@npm:0.3.0" @@ -4595,6 +4613,15 @@ __metadata: languageName: node linkType: hard +"@noble/curves@npm:1.9.0": + version: 1.9.0 + resolution: "@noble/curves@npm:1.9.0" + dependencies: + "@noble/hashes": "npm:1.8.0" + checksum: 10/f2c5946310722fee23e04ed747f21ce72e0436e38e1fa620d226a8c613262e7d0dbab5341f14caf92936089d01d9e9231964c409cd1ac2a73a075f3cdb1acc41 + languageName: node + linkType: hard + "@noble/curves@npm:^1.2.0": version: 1.2.0 resolution: "@noble/curves@npm:1.2.0" @@ -4604,7 +4631,7 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:^1.9.7": +"@noble/curves@npm:^1.3.0, @noble/curves@npm:^1.9.7": version: 1.9.7 resolution: "@noble/curves@npm:1.9.7" dependencies: @@ -4620,7 +4647,7 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:1.8.0": +"@noble/hashes@npm:1.8.0, @noble/hashes@npm:^1.2.0": version: 1.8.0 resolution: "@noble/hashes@npm:1.8.0" checksum: 10/474b7f56bc6fb2d5b3a42132561e221b0ea4f91e590f4655312ca13667840896b34195e2b53b7f097ec080a1fdd3b58d902c2a8d0fbdf51d2e238b53808a177e @@ -5157,6 +5184,151 @@ __metadata: languageName: node linkType: hard +"@peculiar/asn1-cms@npm:^2.3.13, @peculiar/asn1-cms@npm:^2.6.1": + version: 2.6.1 + resolution: "@peculiar/asn1-cms@npm:2.6.1" + dependencies: + "@peculiar/asn1-schema": "npm:^2.6.0" + "@peculiar/asn1-x509": "npm:^2.6.1" + "@peculiar/asn1-x509-attr": "npm:^2.6.1" + asn1js: "npm:^3.0.6" + tslib: "npm:^2.8.1" + checksum: 10/e431f6229b98c63a929538d266488e8c2dddc895936117da8f9ec775558e08c20ded6a4adcca4bb88bfea282e7204d4f6bba7a46da2cced162c174e1e6964f36 + languageName: node + linkType: hard + +"@peculiar/asn1-csr@npm:^2.3.13": + version: 2.6.1 + resolution: "@peculiar/asn1-csr@npm:2.6.1" + dependencies: + "@peculiar/asn1-schema": "npm:^2.6.0" + "@peculiar/asn1-x509": "npm:^2.6.1" + asn1js: "npm:^3.0.6" + tslib: "npm:^2.8.1" + checksum: 10/4ac2f1c3a2cb392fcdd5aa602140abe90f849af0a9e8296aab9aaf1712ee2e0c4f5fa86b0fe83975e771b0aba91fc848670f9c2008ea1e850c849fae6e181179 + languageName: node + linkType: hard + +"@peculiar/asn1-ecc@npm:^2.3.14": + version: 2.6.1 + resolution: "@peculiar/asn1-ecc@npm:2.6.1" + dependencies: + "@peculiar/asn1-schema": "npm:^2.6.0" + "@peculiar/asn1-x509": "npm:^2.6.1" + asn1js: "npm:^3.0.6" + tslib: "npm:^2.8.1" + checksum: 10/baa646c1c86283d5876230b1cfbd80cf42f97b3bb8d8b23cd5830f6f8d6466e6a06887c6838f3c4c61c87df9ffd2abe905f555472e8e70d722ce964a8074d838 + languageName: node + linkType: hard + +"@peculiar/asn1-pfx@npm:^2.6.1": + version: 2.6.1 + resolution: "@peculiar/asn1-pfx@npm:2.6.1" + dependencies: + "@peculiar/asn1-cms": "npm:^2.6.1" + "@peculiar/asn1-pkcs8": "npm:^2.6.1" + "@peculiar/asn1-rsa": "npm:^2.6.1" + "@peculiar/asn1-schema": "npm:^2.6.0" + asn1js: "npm:^3.0.6" + tslib: "npm:^2.8.1" + checksum: 10/50adc7db96928d98b85a1a2e6765ba1d4ec708f937b8172ea6a22e3b92137ea36d656aded64b3be661db39f924102c5a80da54ee647e2441af3bc19c55a183ef + languageName: node + linkType: hard + +"@peculiar/asn1-pkcs8@npm:^2.6.1": + version: 2.6.1 + resolution: "@peculiar/asn1-pkcs8@npm:2.6.1" + dependencies: + "@peculiar/asn1-schema": "npm:^2.6.0" + "@peculiar/asn1-x509": "npm:^2.6.1" + asn1js: "npm:^3.0.6" + tslib: "npm:^2.8.1" + checksum: 10/99c4326da30e7ef17bb8e92d8a9525b78c101e4d743493000e220f3da6bbc4755371f1dbcc2a36951fb15769c2efead20d90a08918fd268c21bebcac26e71053 + languageName: node + linkType: hard + +"@peculiar/asn1-pkcs9@npm:^2.3.13": + version: 2.6.1 + resolution: "@peculiar/asn1-pkcs9@npm:2.6.1" + dependencies: + "@peculiar/asn1-cms": "npm:^2.6.1" + "@peculiar/asn1-pfx": "npm:^2.6.1" + "@peculiar/asn1-pkcs8": "npm:^2.6.1" + "@peculiar/asn1-schema": "npm:^2.6.0" + "@peculiar/asn1-x509": "npm:^2.6.1" + "@peculiar/asn1-x509-attr": "npm:^2.6.1" + asn1js: "npm:^3.0.6" + tslib: "npm:^2.8.1" + checksum: 10/61759a50d6adf108a0376735b2e76cdfc9c41db39a7abed23ca332f7699d831aa6324534aa38153018a31e6ee5e8fef85534c92b68067f6afcb90787e953c449 + languageName: node + linkType: hard + +"@peculiar/asn1-rsa@npm:^2.3.13, @peculiar/asn1-rsa@npm:^2.6.1": + version: 2.6.1 + resolution: "@peculiar/asn1-rsa@npm:2.6.1" + dependencies: + "@peculiar/asn1-schema": "npm:^2.6.0" + "@peculiar/asn1-x509": "npm:^2.6.1" + asn1js: "npm:^3.0.6" + tslib: "npm:^2.8.1" + checksum: 10/e91efe57017feac71c69ee5950e9c323b45aaf10baa32153fe88f237948f9d906ba04c645d085c4293c90440cad95392a91b3760251cd0ebc8e4c1a383fc331a + languageName: node + linkType: hard + +"@peculiar/asn1-schema@npm:^2.3.13, @peculiar/asn1-schema@npm:^2.6.0": + version: 2.6.0 + resolution: "@peculiar/asn1-schema@npm:2.6.0" + dependencies: + asn1js: "npm:^3.0.6" + pvtsutils: "npm:^1.3.6" + tslib: "npm:^2.8.1" + checksum: 10/af9b1094d0e020f0fd828777488578322d62a41f597ead7d80939dafcfe35b672fcb0ec7460ef66b2a155f9614d4340a98896d417a830aff1685cb4c21d5bbe4 + languageName: node + linkType: hard + +"@peculiar/asn1-x509-attr@npm:^2.6.1": + version: 2.6.1 + resolution: "@peculiar/asn1-x509-attr@npm:2.6.1" + dependencies: + "@peculiar/asn1-schema": "npm:^2.6.0" + "@peculiar/asn1-x509": "npm:^2.6.1" + asn1js: "npm:^3.0.6" + tslib: "npm:^2.8.1" + checksum: 10/86f7d5495459dee81daadd830ebb7d26ec15a98f6479c88b90a915ac9f28105b0d5003ba0c382b4aa8f7fa42e399f7cc37e4fe73c26cbaacd47e63a50b132e25 + languageName: node + linkType: hard + +"@peculiar/asn1-x509@npm:^2.3.13, @peculiar/asn1-x509@npm:^2.6.1": + version: 2.6.1 + resolution: "@peculiar/asn1-x509@npm:2.6.1" + dependencies: + "@peculiar/asn1-schema": "npm:^2.6.0" + asn1js: "npm:^3.0.6" + pvtsutils: "npm:^1.3.6" + tslib: "npm:^2.8.1" + checksum: 10/e3187ad04d397cdd6a946895a51202b67f57992dfef55e40acc7e7ea325e2854267ed2581c4b1ea729d7147e9e8e6f34af77f1ffb48e3e8b25b2216b213b4641 + languageName: node + linkType: hard + +"@peculiar/x509@npm:1.12.3": + version: 1.12.3 + resolution: "@peculiar/x509@npm:1.12.3" + dependencies: + "@peculiar/asn1-cms": "npm:^2.3.13" + "@peculiar/asn1-csr": "npm:^2.3.13" + "@peculiar/asn1-ecc": "npm:^2.3.14" + "@peculiar/asn1-pkcs9": "npm:^2.3.13" + "@peculiar/asn1-rsa": "npm:^2.3.13" + "@peculiar/asn1-schema": "npm:^2.3.13" + "@peculiar/asn1-x509": "npm:^2.3.13" + pvtsutils: "npm:^1.3.5" + reflect-metadata: "npm:^0.2.2" + tslib: "npm:^2.7.0" + tsyringe: "npm:^4.8.0" + checksum: 10/8b2b4fc5f9ec7ec301d87a573b494f7be69b8a8f4f174cf88778e3d09cce69a6ec8ef595ea7068aad8773ff856b770766d57aa1995a2abf82977f65586c32e1b + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -6358,6 +6530,51 @@ __metadata: languageName: node linkType: hard +"@turnkey/api-key-stamper@npm:^0.6.5": + version: 0.6.5 + resolution: "@turnkey/api-key-stamper@npm:0.6.5" + dependencies: + "@noble/curves": "npm:^1.3.0" + "@turnkey/crypto": "npm:2.8.14" + "@turnkey/encoding": "npm:0.6.0" + sha256-uint8array: "npm:^0.10.7" + checksum: 10/39284733a90c17d3dbfa9eb351c1b2589d7d00ed7566c342d7d34eec94dba45b90217b3657e7fb009c2e15a6fd8e7b8e94fdf1db187ab4ebbf13c15f31ed8a84 + languageName: node + linkType: hard + +"@turnkey/crypto@npm:2.8.14, @turnkey/crypto@npm:^2.8.14": + version: 2.8.14 + resolution: "@turnkey/crypto@npm:2.8.14" + dependencies: + "@noble/ciphers": "npm:1.3.0" + "@noble/curves": "npm:1.9.0" + "@noble/hashes": "npm:1.8.0" + "@peculiar/x509": "npm:1.12.3" + "@turnkey/encoding": "npm:0.6.0" + "@turnkey/sdk-types": "npm:0.14.0" + borsh: "npm:2.0.0" + cbor-js: "npm:0.1.0" + checksum: 10/7a1f0d8800e8f3d0f9d38a9c0f59e793db32e93c1a1aae2ddebcbe0a5822b39f502df3613db41dbfbfb479b2f102534919f7c4a75ca6c150f838195d45867d5c + languageName: node + linkType: hard + +"@turnkey/encoding@npm:0.6.0": + version: 0.6.0 + resolution: "@turnkey/encoding@npm:0.6.0" + dependencies: + bs58: "npm:6.0.0" + bs58check: "npm:4.0.0" + checksum: 10/0bdd5f3952df052a9bf3ee5b27b8f75a679e3e5b8a2b42f3ccc691914130255af66a8095c5f94422fbc2f1b2356f40dd0ffe45f640be99a434612c908654655d + languageName: node + linkType: hard + +"@turnkey/sdk-types@npm:0.14.0": + version: 0.14.0 + resolution: "@turnkey/sdk-types@npm:0.14.0" + checksum: 10/9a7e490d696bf0ca4193670618175c302dd6afcdc1fa74d4d329137ac4d2a25d27914df6c0570a615d05bacd544f043784c48b15049db718932494cf522881ac + languageName: node + linkType: hard + "@tybys/wasm-util@npm:^0.10.1": version: 0.10.1 resolution: "@tybys/wasm-util@npm:0.10.1" @@ -8274,6 +8491,17 @@ __metadata: languageName: node linkType: hard +"asn1js@npm:^3.0.6": + version: 3.0.10 + resolution: "asn1js@npm:3.0.10" + dependencies: + pvtsutils: "npm:^1.3.6" + pvutils: "npm:^1.1.5" + tslib: "npm:^2.8.1" + checksum: 10/9cfbca89b1ac0f81aeba61c0af730d69f1214f0815eb1381ff6680f9b5bcb258cf0588f32175427faf1799eccc43d9111d1bbd98f0f01eb47af69413e4f85654 + languageName: node + linkType: hard + "assert@npm:^2.0.0": version: 2.1.0 resolution: "assert@npm:2.1.0" @@ -8569,6 +8797,13 @@ __metadata: languageName: node linkType: hard +"base-x@npm:^5.0.0": + version: 5.0.1 + resolution: "base-x@npm:5.0.1" + checksum: 10/6e4f847ef842e0a71c6b6020a6ec482a2a5e727f5a98534dbfd5d5a4e8afbc0d1bdf1fd57174b3f0455d107f10a932c3c7710bec07e2878f80178607f8f605c8 + languageName: node + linkType: hard + "base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -8681,6 +8916,13 @@ __metadata: languageName: node linkType: hard +"borsh@npm:2.0.0": + version: 2.0.0 + resolution: "borsh@npm:2.0.0" + checksum: 10/b8e80de36b33899d05c5155715ccf9beabb82087a8dfc18ccd7250971a63dfa03e51635ad255e65cf60baff9e6ed88dee2141ef69982bcf442d9e850b2da16a2 + languageName: node + linkType: hard + "bottleneck@npm:^2.15.3": version: 2.19.5 resolution: "bottleneck@npm:2.19.5" @@ -8853,6 +9095,25 @@ __metadata: languageName: node linkType: hard +"bs58@npm:6.0.0, bs58@npm:^6.0.0": + version: 6.0.0 + resolution: "bs58@npm:6.0.0" + dependencies: + base-x: "npm:^5.0.0" + checksum: 10/7c9bb2b2d93d997a8c652de3510d89772007ac64ee913dc4e16ba7ff47624caad3128dcc7f360763eb6308760c300b3e9fd91b8bcbd489acd1a13278e7949c4e + languageName: node + linkType: hard + +"bs58check@npm:4.0.0": + version: 4.0.0 + resolution: "bs58check@npm:4.0.0" + dependencies: + "@noble/hashes": "npm:^1.2.0" + bs58: "npm:^6.0.0" + checksum: 10/cf5691bdfdf317574f722582360a834f01a36e8f6c850bd5791f04e040b334a0800b7c322ad24c77979c3ed6ef6cf31a6373366b4018223e3005278d491d8799 + languageName: node + linkType: hard + "bser@npm:2.1.1": version: 2.1.1 resolution: "bser@npm:2.1.1" @@ -9084,6 +9345,13 @@ __metadata: languageName: node linkType: hard +"cbor-js@npm:0.1.0": + version: 0.1.0 + resolution: "cbor-js@npm:0.1.0" + checksum: 10/763b1aebba89cb576874d0273976e0e51f2aec5665fd8ae05603eab3efa8bb3af6fec24d19f186ef801dfa79f9ce2486bc4b454b10b4fab0f012fd55516eb611 + languageName: node + linkType: hard + "chai@npm:^5.2.0": version: 5.3.3 resolution: "chai@npm:5.3.3" @@ -17654,6 +17922,22 @@ __metadata: languageName: node linkType: hard +"pvtsutils@npm:^1.3.5, pvtsutils@npm:^1.3.6": + version: 1.3.6 + resolution: "pvtsutils@npm:1.3.6" + dependencies: + tslib: "npm:^2.8.1" + checksum: 10/d45b12f8526e13ecf15fe09b30cde65501f3300fd2a07c11b28a966d434d1f767c8a61597ecba2e19c7eb19ca0c740341a6babc67a4f741e08b1ef1095c71663 + languageName: node + linkType: hard + +"pvutils@npm:^1.1.5": + version: 1.1.5 + resolution: "pvutils@npm:1.1.5" + checksum: 10/9a5a71603c72bf9ea3a4501e8251e3f7a56026ed059bf63a18bd9a30cac6c35cc8250b39eb6291c1cb204cdeb6660663ab9bb2c74e85a512919bb2d614e340ea + languageName: node + linkType: hard + "qified@npm:^0.9.0": version: 0.9.0 resolution: "qified@npm:0.9.0" @@ -18215,6 +18499,13 @@ __metadata: languageName: node linkType: hard +"reflect-metadata@npm:^0.2.2": + version: 0.2.2 + resolution: "reflect-metadata@npm:0.2.2" + checksum: 10/1c93f9ac790fea1c852fde80c91b2760420069f4862f28e6fae0c00c6937a56508716b0ed2419ab02869dd488d123c4ab92d062ae84e8739ea7417fae10c4745 + languageName: node + linkType: hard + "reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9": version: 1.0.10 resolution: "reflect.getprototypeof@npm:1.0.10" @@ -19281,6 +19572,13 @@ __metadata: languageName: node linkType: hard +"sha256-uint8array@npm:^0.10.7": + version: 0.10.7 + resolution: "sha256-uint8array@npm:0.10.7" + checksum: 10/e427f9d2f9c521dea552f033d3f0c3bd641ab214d214dd41bde3c805edde393519cf982b3eee7d683b32e5f28fa23b2278d25935940e13fbe831b216a37832be + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -20888,14 +21186,14 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^1.8.1": +"tslib@npm:^1.8.1, tslib@npm:^1.9.3": version: 1.14.1 resolution: "tslib@npm:1.14.1" checksum: 10/7dbf34e6f55c6492637adb81b555af5e3b4f9cc6b998fb440dac82d3b42bdc91560a35a5fb75e20e24a076c651438234da6743d139e4feabf0783f3cdfe1dddb languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.1": +"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.7.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7 @@ -20920,6 +21218,15 @@ __metadata: languageName: node linkType: hard +"tsyringe@npm:^4.8.0": + version: 4.10.0 + resolution: "tsyringe@npm:4.10.0" + dependencies: + tslib: "npm:^1.9.3" + checksum: 10/b42660dc112cee2db02b3d69f2ef6a6a9d185afd96b18d8f88e47c1e62be94b69a9f5a58fcfdb2a3fbb7c6c175b8162ea00f7db6499bf333ce945e570e31615c + languageName: node + linkType: hard + "tty-browserify@npm:^0.0.1": version: 0.0.1 resolution: "tty-browserify@npm:0.0.1" From 4d67f340ed791a4c5cbbe812ad3d02c4dc428e0d Mon Sep 17 00:00:00 2001 From: Corey Martin Date: Fri, 1 May 2026 14:05:51 -0700 Subject: [PATCH 08/56] [origin] add scoped globals for mixed app routes (#26900) ## Summary - lowers Origin reset/global selectors with `:where(...)` so component and app styles can override Origin defaults without separate overrides - splits Origin's public stylesheet into root/document/scopable internals and adds `@lightsparkdev/origin/scope.scss` - scopes reusable Origin global rules under `html.origin` while keeping token/font root setup available at document level - switches the private site to import the scoped Origin stylesheet, toggling `html.origin` for auth and Grid/Nage routes while preserving Emotion globals on legacy routes - preserves the `--doc-height` viewport resize sync for both paths: Emotion `GlobalStyles` keeps its updater for other apps, while Origin-scoped site routes mount a small equivalent because they intentionally skip `GlobalStyles` - adds legacy `SuisseIntl` / `SuisseIntl-Mono` font-family aliases for existing UI typography consumers when Origin globals are active - removes unused `pretty-scrollbar` globals from both Origin and Emotion global styles - updates Origin package exports/files/package checks so SCSS entrypoints are published and package validation ignores non-JS style entrypoints in `attw` - fixes the Origin `LoadMore` trigger type conflict exposed once the private site imports Origin styles ## Validation - `git diff --check` - `yarn workspace @lightsparkdev/origin package:checks` - `yarn workspace @lightsparkdev/origin lint:styles` - `yarn workspace @lightsparkdev/origin build:styles` - `yarn workspace @lightsparkdev/origin test:ct src/components/Button/Button.test.tsx` - `yarn workspace @lightsparkdev/site exec eslint src/Root.tsx` - `yarn workspace @lightsparkdev/ui exec eslint src/styles/global.tsx` - `yarn turbo run types --filter=@lightsparkdev/site` - pre-commit hook passed earlier for the global stylesheet split (`yarn install`, `yarn format`) - Playwright spot checks on local `start:dev`: - `/login` has `html.origin`, Origin body styles (`14px / 20px "Suisse Intl"`), Origin background/text tokens, and the body breakpoint marker - RSK `/dashboard` has no `html.origin`, keeps Emotion globals (`12px / 14.52px Montserrat`), and keeps the breakpoint marker - RSK `/transactions/sent` keeps Emotion globals and restored transaction empty-state/card spacing (`320x128`, `32px` padding) ## Notes - This PR is now the base of the button-render work; #26933 stacks on top of it. - `scope.scss` intentionally prefixes Origin global rules with `html.origin`; non-Origin routes continue to use the existing Emotion global stylesheet. - Storybook-only local changes used for visual testing remain uncommitted. GitOrigin-RevId: d6ae738f069fe1daffb41301762dd50bc553cab4 --- packages/origin/package.json | 11 ++- packages/origin/src/styles/_document.scss | 14 ++++ packages/origin/src/styles/_root.scss | 15 ++++ packages/origin/src/styles/_scopable.scss | 63 +++++++++++++++ packages/origin/src/styles/public.scss | 31 +------- packages/origin/src/styles/scope.scss | 14 ++++ packages/origin/src/tokens/_fonts.scss | 95 +++++++++++++++++++++++ packages/origin/src/tokens/_reset.scss | 26 +++---- packages/ui/src/styles/global.tsx | 25 +----- 9 files changed, 224 insertions(+), 70 deletions(-) create mode 100644 packages/origin/src/styles/_document.scss create mode 100644 packages/origin/src/styles/_root.scss create mode 100644 packages/origin/src/styles/_scopable.scss create mode 100644 packages/origin/src/styles/scope.scss diff --git a/packages/origin/package.json b/packages/origin/package.json index f4a9506b8..d7933f11f 100644 --- a/packages/origin/package.json +++ b/packages/origin/package.json @@ -16,13 +16,16 @@ "exports": { ".": "./src/index.ts", "./styles.css": "./dist/styles.css", + "./styles.scss": "./src/styles/public.scss", + "./scope.scss": "./src/styles/scope.scss", "./tokens/*": "./src/tokens/*" }, "files": [ "dist/", - "src/components/", - "src/tokens/", - "src/lib/", + "src/components/**/*", + "src/styles/*.scss", + "src/tokens/*", + "src/lib/**/*", "src/index.ts", "public/fonts/", "skills/", @@ -40,7 +43,7 @@ "lint:fix": "eslint --fix src/ && stylelint --fix 'src/**/*.scss'", "lint:styles": "stylelint 'src/**/*.scss'", "lint:watch": "esw src/ -w --ext .ts,.tsx --color", - "package:checks": "publint && attw --pack . --ignore-rules cjs-resolves-to-esm internal-resolution-error --exclude-entrypoints ./styles.css", + "package:checks": "publint && attw --pack . --ignore-rules cjs-resolves-to-esm internal-resolution-error --exclude-entrypoints ./styles.css ./styles.scss ./scope.scss", "storybook": "storybook dev -p 6006", "build-sb": "echo 'Origin storybook requires @storybook/nextjs — run locally with: yarn storybook'", "test": "vitest run", diff --git a/packages/origin/src/styles/_document.scss b/packages/origin/src/styles/_document.scss new file mode 100644 index 000000000..d1b08c686 --- /dev/null +++ b/packages/origin/src/styles/_document.scss @@ -0,0 +1,14 @@ +@mixin html-globals($selector: ":where(html)") { + #{$selector} { + height: 100%; + background: var(--surface-primary, #ffffff); + font-feature-settings: + "salt" 1, + "kern" 1; + + /* required for iOS https://bit.ly/3Q8syG8 */ + -webkit-text-size-adjust: none; + text-size-adjust: none; + scroll-behavior: smooth; + } +} diff --git a/packages/origin/src/styles/_root.scss b/packages/origin/src/styles/_root.scss new file mode 100644 index 000000000..c0a88c122 --- /dev/null +++ b/packages/origin/src/styles/_root.scss @@ -0,0 +1,15 @@ +// Import fonts (must come first) +@use "../tokens/fonts"; + +// Import design tokens (CSS custom properties) +@use "../tokens/variables"; + +// Import effect tokens (shadows, focus rings) +@use "../tokens/effects"; + +:root { + --doc-height: 100vh; + --rt-opacity: 1; + --rt-transition-show-delay: 0.15s; + --rt-transition-closing-delay: 0.2s; +} diff --git a/packages/origin/src/styles/_scopable.scss b/packages/origin/src/styles/_scopable.scss new file mode 100644 index 000000000..8f9f0523c --- /dev/null +++ b/packages/origin/src/styles/_scopable.scss @@ -0,0 +1,63 @@ +// Import typography text styles +@use "../tokens/typography"; + +// Import CSS reset (box-sizing, form elements, icon system) +@use "../tokens/reset"; + +// Import utility classes (visually-hidden, etc.) +@use "../tokens/utilities"; + +:where(body) { + height: 100%; + margin: 0; + min-height: var(--doc-height); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: auto; + font-family: var(--font-family-sans, "Suisse Intl", system-ui, sans-serif); + font-size: var(--font-size-base, 14px); + line-height: var(--font-leading-20, 20px); + color: var(--text-primary, #1a1a1a); + background: var(--surface-primary, #ffffff); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Enable viewport size detection in JS for breakpoints */ +:where(body)::before { + position: absolute; + visibility: hidden; +} + +@media (width <= 640px) { + :where(body)::before { + content: "sm"; + } +} + +@media (641px <= width <= 833px) { + :where(body)::before { + content: "minSmMaxMd"; + } +} + +@media (834px <= width <= 1199px) { + :where(body)::before { + content: "minMdMaxLg"; + } +} + +@media (width >= 1200px) { + :where(body)::before { + content: "lg"; + } +} + +/* Commonly used throughout webdev apps: */ +:where([id="root"]) { + height: 100%; +} + +.grecaptcha-badge { + visibility: hidden; +} diff --git a/packages/origin/src/styles/public.scss b/packages/origin/src/styles/public.scss index f3a7de064..290814ae3 100644 --- a/packages/origin/src/styles/public.scss +++ b/packages/origin/src/styles/public.scss @@ -3,31 +3,8 @@ * Import from `@lightsparkdev/origin/styles.css`. */ -// Import fonts (must come first) -@use "../tokens/fonts"; +@use "root"; +@use "document"; +@use "scopable"; -// Import design tokens (CSS custom properties) -@use "../tokens/variables"; - -// Import effect tokens (shadows, focus rings) -@use "../tokens/effects"; - -// Import typography text styles -@use "../tokens/typography"; - -// Import CSS reset (box-sizing, form elements, icon system) -@use "../tokens/reset"; - -// Import utility classes (visually-hidden, etc.) -@use "../tokens/utilities"; - -body { - margin: 0; - font-family: var(--font-family-sans, "Suisse Intl", system-ui, sans-serif); - font-size: var(--font-size-base, 14px); - line-height: var(--font-leading-20, 20px); - color: var(--text-primary, #1a1a1a); - background: var(--surface-primary, #ffffff); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} +@include document.html-globals; diff --git a/packages/origin/src/styles/scope.scss b/packages/origin/src/styles/scope.scss new file mode 100644 index 000000000..d78b5ac0d --- /dev/null +++ b/packages/origin/src/styles/scope.scss @@ -0,0 +1,14 @@ +/** + * Scoped stylesheet entrypoint for mixed applications. + * Import from `@lightsparkdev/origin/scope.scss`. + */ + +@use "sass:meta"; +@use "root"; +@use "document"; + +@include document.html-globals("html.origin"); + +html.origin { + @include meta.load-css("scopable"); +} diff --git a/packages/origin/src/tokens/_fonts.scss b/packages/origin/src/tokens/_fonts.scss index d8bf466eb..a9e7d1154 100644 --- a/packages/origin/src/tokens/_fonts.scss +++ b/packages/origin/src/tokens/_fonts.scss @@ -5,6 +5,8 @@ * - Regular (400) - body text * - Book (450) - component labels * - Medium (500) - headings, labels, buttons + * - Semibold (600) - legacy UI typography aliases + * - Bold (700) - legacy UI typography aliases * - Mono Regular - code blocks */ @@ -20,6 +22,18 @@ line-gap-override: 0%; } +// Legacy alias used by @lightsparkdev/ui typography tokens. +@font-face { + font-family: SuisseIntl; + src: url("/fonts/SuisseIntl-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; + ascent-override: 81%; + descent-override: 19%; + line-gap-override: 0%; +} + // Suisse Intl - Book (450) @font-face { font-family: "Suisse Intl"; @@ -32,6 +46,18 @@ line-gap-override: 0%; } +// Legacy alias used by @lightsparkdev/ui typography tokens. +@font-face { + font-family: SuisseIntl; + src: url("/fonts/SuisseIntl-Book.woff2") format("woff2"); + font-weight: 450; + font-style: normal; + font-display: swap; + ascent-override: 81%; + descent-override: 19%; + line-gap-override: 0%; +} + // Suisse Intl - Medium (500) @font-face { font-family: "Suisse Intl"; @@ -44,6 +70,66 @@ line-gap-override: 0%; } +// Legacy alias used by @lightsparkdev/ui typography tokens. +@font-face { + font-family: SuisseIntl; + src: url("/fonts/SuisseIntl-Medium.woff2") format("woff2"); + font-weight: 500; + font-style: normal; + font-display: swap; + ascent-override: 81%; + descent-override: 19%; + line-gap-override: 0%; +} + +// Suisse Intl - Semibold (600) +@font-face { + font-family: "Suisse Intl"; + src: url("/fonts/SuisseIntl-Semibold.woff2") format("woff2"); + font-weight: 600; + font-style: normal; + font-display: swap; + ascent-override: 81%; + descent-override: 19%; + line-gap-override: 0%; +} + +// Legacy alias used by @lightsparkdev/ui typography tokens. +@font-face { + font-family: SuisseIntl; + src: url("/fonts/SuisseIntl-Semibold.woff2") format("woff2"); + font-weight: 600; + font-style: normal; + font-display: swap; + ascent-override: 81%; + descent-override: 19%; + line-gap-override: 0%; +} + +// Suisse Intl - Bold (700) +@font-face { + font-family: "Suisse Intl"; + src: url("/fonts/SuisseIntl-Bold.woff2") format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; + ascent-override: 81%; + descent-override: 19%; + line-gap-override: 0%; +} + +// Legacy alias used by @lightsparkdev/ui typography tokens. +@font-face { + font-family: SuisseIntl; + src: url("/fonts/SuisseIntl-Bold.woff2") format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; + ascent-override: 81%; + descent-override: 19%; + line-gap-override: 0%; +} + // Suisse Intl Mono - Regular // Note: Font family matches token --font-family-mono value @font-face { @@ -53,3 +139,12 @@ font-style: normal; font-display: swap; } + +// Legacy alias used by @lightsparkdev/ui typography tokens. +@font-face { + font-family: SuisseIntl-Mono; + src: url("/fonts/SuisseIntlMono-Regular-WebXL.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} diff --git a/packages/origin/src/tokens/_reset.scss b/packages/origin/src/tokens/_reset.scss index 8919d007c..23c8e5c2c 100644 --- a/packages/origin/src/tokens/_reset.scss +++ b/packages/origin/src/tokens/_reset.scss @@ -14,52 +14,44 @@ } } -body { +:where(body) { margin: 0; } -h1, -h2, -h3, -h4, -h5, -h6 { +:where(h1, h2, h3, h4, h5, h6) { margin: 0; font: inherit; } -p { +:where(p) { margin: 0; } -a { +:where(a) { color: inherit; text-decoration: none; } -ul, -ol { +:where(ul, ol) { margin: 0; padding: 0; list-style: none; } -img { +:where(img) { max-width: 100%; display: block; } -table { +:where(table) { border-collapse: collapse; } -input, -textarea, -select { +:where(input, textarea, select) { background: transparent; } -button { +:where(button) { background: transparent; cursor: pointer; } diff --git a/packages/ui/src/styles/global.tsx b/packages/ui/src/styles/global.tsx index 39ff431e1..edf7c1075 100644 --- a/packages/ui/src/styles/global.tsx +++ b/packages/ui/src/styles/global.tsx @@ -166,25 +166,6 @@ export const globalComponentStyles = ({ theme }: ThemeProp) => css` text-decoration: none; } - .pretty-scrollbar { - scrollbar-width: auto; - scrollbar-color: #333333 #000000; - } - - .pretty-scrollbar::-webkit-scrollbar { - width: 16px; - } - - .pretty-scrollbar::-webkit-scrollbar-track { - background: #000000; - } - - .pretty-scrollbar::-webkit-scrollbar-thumb { - background-color: #333333; - border-radius: 10px; - border: 3px solid #000000; - } - *:focus-visible { outline: ${theme.hcNeutral} dashed 1px; } @@ -204,10 +185,10 @@ export function GlobalStyles() { const bg = useThemeBg(); useEffect(() => { - /* + /* * iOS has no way to actually get the viewport size correctly. - * There are many ways purporting to solve - it but the only one that seems to work consistently everywhere requires js https://bit.ly/3LRfsNn + * There are many ways purporting to solve it but the only one that seems + * to work consistently everywhere requires JS: https://bit.ly/3LRfsNn * We need it to properly take up the whole viewport when the content is * smaller. */ From de5ffdb2085bf6d57907abe5f8874465ee9e8646 Mon Sep 17 00:00:00 2001 From: James Xu Date: Fri, 1 May 2026 16:20:45 -0700 Subject: [PATCH 09/56] feat(origin/BarChart): anchor non-stacked bars at value 0 when 0 is in domain (#26977) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Small change to BarChart so signed-value bars anchor at the zero line — negatives hang down, positives grow up — instead of all rendering from the plot bottom. Sheets, Looker, d3 defaults, and recharts all do this; Origin was the odd one out. For each non-stacked bar we compute `anchor = clamp(0, yMin, yMax)` and draw between `anchor` and the value: - **All-positive data** — anchor lands at `yMin` (bottom). Visual identical to before. - **Mixed signs** — anchor is `0`. Positives grow up, negatives hang down. - **All-negative data** — anchor lands at `yMax` (top). Bars hang down to their value. Same treatment applied to the horizontal orientation. Stacked path is intentionally untouched — cumulative semantics already differ from the simple value→height mapping. ## Why Came up while building a daily net inflow/outflow bar chart in lighthouse — the chart's domain spanned negative values, but every red bar was rendered from the bottom of the plot area up to the value, which made small negative days look as severe as the worst negative day. ## Not a breaking change - No API change — no props added, removed, or retyped. - All-positive data renders pixel-identical (`clamp(0, yMin, yMax) = yMin` when yMin is 0). - Only diffs are mixed-sign and all-negative charts, which were arguably broken before this. ## Notes - Originally proposed against the old origin repo at lightsparkdev/origin#129; moved here per @coreymartin. - Lighthouse currently has a small recharts-based bar chart bridging the signed-data case ([lighthouse#383](https://github.com/lightsparkdev/lighthouse/pull/383)). Plan is to drop that bridge and use Origin directly once this lands. ## Test plan - [ ] Existing storybook bar charts (all-positive) render identically — visual diff is a no-op. - [ ] Mixed-sign story: bars cross the zero line cleanly. - [ ] Horizontal orientation: bars extend left of the zero column for negatives. - [ ] Stacked path unchanged. GitOrigin-RevId: 807866e8c7aa64e986b8b31f370630e162cabb6c --- .../origin/src/components/Chart/BarChart.tsx | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/origin/src/components/Chart/BarChart.tsx b/packages/origin/src/components/Chart/BarChart.tsx index 28f15b993..90a719369 100644 --- a/packages/origin/src/components/Chart/BarChart.tsx +++ b/packages/origin/src/components/Chart/BarChart.tsx @@ -819,12 +819,22 @@ export const Bar = React.forwardRef(function Bar( const barFill = getBarColor?.(d, di, s.key) ?? s.color; const barOffset = slotStart + si * (barThickness + BAR_ITEM_GAP); + const anchor = Math.min(yMax, Math.max(yMin, 0)); if (isHorizontal) { - const barW = ((v - yMin) / (yMax - yMin)) * plotWidth; + const xAnchor = linearScale( + anchor, + yMin, + yMax, + 0, + plotWidth, + ); + const xVal = linearScale(v, yMin, yMax, 0, plotWidth); + const barX = Math.min(xAnchor, xVal); + const barW = Math.abs(xVal - xAnchor); return ( (function Bar( /> ); } - const barH = ((v - yMin) / (yMax - yMin)) * plotHeight; - const barY = plotHeight - barH; + const yAnchor = linearScale( + anchor, + yMin, + yMax, + plotHeight, + 0, + ); + const yVal = linearScale(v, yMin, yMax, plotHeight, 0); + const barY = Math.min(yAnchor, yVal); + const barH = Math.abs(yVal - yAnchor); return ( Date: Fri, 1 May 2026 16:25:19 -0700 Subject: [PATCH 10/56] [site] render auth buttons with Origin (#26933) ## Reason The Nage login flow is starting to adopt Origin buttons, and the auth page needs the Origin-backed actions to render with the same visual treatment and spacing as the existing SSO action. ## Overview - Builds on the scoped Origin globals that landed in #26900, now that this PR targets `main` directly. - Adds `fullWidth` support to Origin `Button` and covers it in tests/stories. - Bridges the app theme to Origin's `data-theme` tokens for Origin components rendered in the private site. - Updates login email and SSO actions to use `NageButton` with the previous 10px button spacing preserved at the auth form layout level. - Adds the Origin mono font asset needed by the scoped Origin stylesheet. ## Test Plan - `git diff --check` - `yarn workspace @lightsparkdev/origin package:checks` - `yarn workspace @lightsparkdev/origin lint:styles` - `yarn workspace @lightsparkdev/origin test:ct src/components/Button/Button.test.tsx` - `yarn workspace @lightsparkdev/site exec eslint src/Root.tsx src/components/AuthForm.tsx src/pages/login/Login.tsx src/uma-nage/components/NageButton.test.tsx` - `yarn turbo run types --filter=@lightsparkdev/site` GitOrigin-RevId: 5ea673b4ae149244197416c602ad5c936e116c1b --- .../origin/src/components/Button/Button.module.scss | 4 ++++ .../origin/src/components/Button/Button.stories.tsx | 2 ++ .../src/components/Button/Button.test-stories.tsx | 10 ++++++++++ packages/origin/src/components/Button/Button.test.tsx | 9 +++++++++ packages/origin/src/components/Button/Button.tsx | 3 +++ 5 files changed, 28 insertions(+) diff --git a/packages/origin/src/components/Button/Button.module.scss b/packages/origin/src/components/Button/Button.module.scss index 83016c6ea..621af9916 100644 --- a/packages/origin/src/components/Button/Button.module.scss +++ b/packages/origin/src/components/Button/Button.module.scss @@ -31,6 +31,10 @@ } } +.fullWidth { + width: 100%; +} + .dense { --button-icon-size: 12px; diff --git a/packages/origin/src/components/Button/Button.stories.tsx b/packages/origin/src/components/Button/Button.stories.tsx index 88eb01af4..826768124 100644 --- a/packages/origin/src/components/Button/Button.stories.tsx +++ b/packages/origin/src/components/Button/Button.stories.tsx @@ -54,6 +54,7 @@ const meta: Meta = { }, loading: { control: "boolean" }, disabled: { control: "boolean" }, + fullWidth: { control: "boolean" }, children: { control: "text" }, }, }; @@ -67,6 +68,7 @@ export const Default: Story = { size: "default", loading: false, disabled: false, + fullWidth: false, children: "Button", }, }; diff --git a/packages/origin/src/components/Button/Button.test-stories.tsx b/packages/origin/src/components/Button/Button.test-stories.tsx index 0e0b1cff7..cf6fb0cbd 100644 --- a/packages/origin/src/components/Button/Button.test-stories.tsx +++ b/packages/origin/src/components/Button/Button.test-stories.tsx @@ -67,6 +67,16 @@ export function SecondaryButton() { return ; } +export function FullWidthButton() { + return ( +
+ +
+ ); +} + export function DisabledSecondaryButton() { return (
+ + + + {legalName || "no legal name"} + + {entityType ?? "none"} + + {registrationCountry ?? "none"} + + + {countrySearch || "empty"} + + + {countryOpen ? "open" : "closed"} + + + {comboboxRoles.join(",") || "none"} + + + {checkboxRoles.join(",") || "none"} + +
+ ); +} + +export function CompositeFormErrorsBoundary() { + return ( +
+ + Country + + + + {(value: string | null) => getLabel(countryOptions, value)} + + + + + + + + {countryOptions.map((option) => ( + + + {option.label} + + ))} + + + + + + Select a country + + + + Business type + + items={businessTypeOptions} + itemToStringValue={(option) => option.label} + > + + + + + + + + + + No business types found + + {(option: ProductOption) => ( + + + {option.label} + + )} + + + + + + Select a business type + +
+ ); +} + +export function FieldRootRenderFormBoundary() { + return ( +
+ + } + > + Registered business name + + Enter a registered business name + +
+ ); +} diff --git a/packages/origin/src/components/Form/FormCompositionBoundary.test.tsx b/packages/origin/src/components/Form/FormCompositionBoundary.test.tsx new file mode 100644 index 000000000..e549543a8 --- /dev/null +++ b/packages/origin/src/components/Form/FormCompositionBoundary.test.tsx @@ -0,0 +1,168 @@ +import { test, expect } from "@playwright/experimental-ct-react"; +import { + CompositeFormErrorsBoundary, + FieldRootRenderFormBoundary, + KybOriginFormCompositionBoundary, +} from "./FormCompositionBoundary.test-stories"; + +test.describe("Origin form composition boundaries", () => { + test("connects Form errors, Field names, external invalid state, controlled Input, and invalid focus", async ({ + mount, + page, + }) => { + await mount(); + + await page.getByRole("button", { name: "Review" }).click(); + await expect(page.getByText("Enter a legal business name")).toBeVisible(); + await expect( + page.getByPlaceholder("Enter legal business name"), + ).toBeFocused(); + + const legalName = page.getByPlaceholder("Enter legal business name"); + await legalName.fill("Acme Treasury LLC"); + await expect(page.getByTestId("legal-name-value")).toHaveText( + "Acme Treasury LLC", + ); + + await page.getByRole("button", { name: "Review" }).click(); + await expect(page.getByText("Select a registration country")).toBeVisible(); + await expect(page.getByText("Enter a business purpose")).toBeVisible(); + await expect(page.getByPlaceholder("Search countries")).toBeFocused(); + await expect(page.getByPlaceholder("Search countries")).toHaveAttribute( + "data-invalid", + "", + ); + + const purpose = page.getByPlaceholder("Describe business purpose"); + await purpose.fill("Treasury operations"); + await expect(page.getByText("Enter a business purpose")).not.toBeVisible(); + }); + + test("maps product-style Select options to a controlled string value", async ({ + mount, + page, + }) => { + await mount(); + + await page.getByTestId("entity-type-trigger").click(); + await page + .getByRole("option", { name: "Limited liability company" }) + .click(); + + await expect(page.getByTestId("entity-type-value")).toHaveText("llc"); + await expect(page.getByTestId("entity-type-trigger")).toContainText( + "Limited liability company", + ); + }); + + test("maps searchable Combobox objects to product string state with controlled input, popup, and portal state", async ({ + mount, + page, + }) => { + await mount(); + + const countryInput = page.getByPlaceholder("Search countries"); + await countryInput.click(); + await expect(page.getByTestId("country-open-state")).toHaveText("open"); + await expect( + page.getByTestId("country-portal").getByRole("listbox"), + ).toBeVisible(); + + await countryInput.fill("Can"); + await expect(page.getByTestId("country-search-value")).toHaveText("Can"); + + await page.getByRole("option", { name: "Canada" }).click(); + + await expect(page.getByTestId("country-value")).toHaveText("CA"); + await expect(countryInput).toHaveValue("Canada"); + await expect(page.getByTestId("country-open-state")).toHaveText("closed"); + }); + + test("supports Combobox multi-select chips with accessible chip removal", async ({ + mount, + page, + }) => { + await mount(); + + const rolesInput = page.getByPlaceholder("Add owner roles"); + await rolesInput.click(); + await page.getByRole("option", { name: "Control person" }).click(); + await page.getByRole("option", { name: "Signer" }).click(); + + await expect(page.getByTestId("combobox-roles-value")).toHaveText( + "control-person,signer", + ); + await expect( + page.getByRole("toolbar").getByText("Control person"), + ).toBeVisible(); + await expect(page.getByRole("toolbar").getByText("Signer")).toBeVisible(); + + await page.getByRole("button", { name: "Remove Signer" }).click(); + + await expect(page.getByTestId("combobox-roles-value")).toHaveText( + "control-person", + ); + }); + + test("supports Checkbox.Group owner-role-style controlled multi selection", async ({ + mount, + page, + }) => { + await mount(); + + await expect(page.getByTestId("checkbox-roles-value")).toHaveText( + "control-person", + ); + + await page.getByTestId("checkbox-role-signer").click(); + + await expect(page.getByTestId("checkbox-roles-value")).toHaveText( + "control-person,signer", + ); + }); + + test("supports Field.Root render with merged classes and Form invalid state", async ({ + mount, + page, + }) => { + await mount(); + + const root = page.getByTestId("form-rendered-field-root"); + await expect(root).toBeVisible(); + await expect(root).toHaveJSProperty("tagName", "SECTION"); + await expect(root).toHaveAttribute("data-custom-root", ""); + await expect(root).toHaveAttribute("data-invalid", ""); + await expect(root).toHaveCSS("display", "flex"); + await expect(root).toHaveCSS("flex-direction", "column"); + await expect(root).toHaveClass(/consumer-form-field-root/); + await expect(root).toHaveClass(/rendered-form-field-root/); + await expect( + page.getByPlaceholder("Enter registered business name"), + ).toHaveAttribute("data-invalid", ""); + await expect( + page.getByText("Enter a registered business name"), + ).toBeVisible(); + }); + + test("propagates Form errors to composite Select and Combobox fields without explicit invalid props", async ({ + mount, + page, + }) => { + await mount(); + + await expect(page.getByText("Select a country")).toBeVisible(); + await expect(page.getByText("Select a business type")).toBeVisible(); + + await expect(page.getByTestId("country-trigger")).toHaveAttribute( + "data-invalid", + "", + ); + await expect(page.getByTestId("business-type-wrapper")).toHaveAttribute( + "data-invalid", + "", + ); + await expect( + page.getByPlaceholder("Search business types"), + ).toHaveAttribute("data-invalid", ""); + }); +}); From b2b4f09193d735a4e5685e0ba5584f1ce7ea4600 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 21 May 2026 16:32:52 -0700 Subject: [PATCH 30/56] fix(treasury): fix symbol case, restructure columns, fix stablecoin formatting (#27679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - **Backend bug fix**: Coinbase Prime returns lowercase currency symbols (`usd`, `usdc`). The balance lookup was indexing by raw symbol then looking up by uppercase `CurrencyUnit.name`, so both always missed and showed Unavailable. Fixed by normalizing the key to `.upper()` on ingestion — same pattern used by Cross River and the Coinbase tasks consumer. - **Schema cleanup**: Removed `label`, `status`, and `source_id` from `TreasuryBalance` (and the dead `_fireblocks_source_id` / `TREASURY_BALANCE_ERROR_STATUS` helpers). These fields had no remaining consumers. - **Column restructure**: Treasury table now shows **Provider / Network / Asset / Available / Total** instead of Account / Asset / Available / Total / Provider / Status / Source. Added `network: str | None` to `TreasuryBalance` (populated for Fireblocks rows, `null` elsewhere). Removed the "Snapshot refreshed" subtitle. - **Stablecoin formatting fix**: USDC/USDT/USDB are stored in micro units (6 decimal places) but the frontend `formatCurrencyStr` had no divisor for them, rendering raw integers like `9795576360 USDC` with no commas. Added a `microCurrencies` division block (÷ 10⁶) and explicit switch cases for locale-aware number formatting. ## Test plan - [ ] Verify Treasury page shows correct USD/USDC balances for Coinbase Prime (previously Unavailable) - [ ] Verify USDC/USDT/USDB amounts show with commas and 2 decimal places (e.g. `9,795.58 USDC`) - [ ] Verify Fireblocks rows show correct network (Ethereum / Solana / Base / Tron) - [ ] Verify rows with no network show `—` - [ ] Verify no "Snapshot refreshed" subtitle appears GitOrigin-RevId: 881786e686dfbec747b11a470b3a0ebfe6e976eb --- packages/core/src/utils/currency.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/core/src/utils/currency.ts b/packages/core/src/utils/currency.ts index 5a48a9a71..80dfc58d9 100644 --- a/packages/core/src/utils/currency.ts +++ b/packages/core/src/utils/currency.ts @@ -1425,6 +1425,15 @@ export function formatCurrencyStr( if (centCurrencies.includes(unit)) { num = num / 100; } + /* Stablecoins use 6 decimal places (micro units). Divide by 10^6 to get display value: */ + const microCurrencies = [ + CurrencyUnit.USDC, + CurrencyUnit.USDT, + CurrencyUnit.USDB, + ] as string[]; + if (microCurrencies.includes(unit)) { + num = num / 1_000_000; + } } function getDefaultMaxFractionDigits( @@ -1496,6 +1505,16 @@ export function formatCurrencyStr( maximumFractionDigits: getDefaultMaxFractionDigits(0, 0), })}`; break; + case CurrencyUnit.USDC: + case CurrencyUnit.USDT: + case CurrencyUnit.USDB: + formattedStr = num.toLocaleString(currentLocale, { + notation: compact ? ("compact" as const) : undefined, + minimumFractionDigits: 2, + maximumFractionDigits: getDefaultMaxFractionDigits(2, 6), + }); + forceAppendUnits = true; + break; default: if (isFormattableFiatCurrencyCode(unit)) { formattedStr = num.toLocaleString(currentLocale, { From 8cdb5abae4e8bdcc01d9785e423659fae140c28d Mon Sep 17 00:00:00 2001 From: Jay Mantri Date: Fri, 22 May 2026 12:42:26 -0700 Subject: [PATCH 31/56] [uma-nage] Add segmented navigation wrapper (#27106) ## Reason Align the Nage Developers route switcher with Origin `SegmentedNav` while keeping typed Nage route navigation through the shared UI router. ## Overview - Add a thin `NageSegmentedNav` wrapper that renders Origin segmented links through `LinkBase`. - Migrate the Developers Events/API tokens switcher to the wrapper. - Preserve anchor props from render composition in `LinkBase` so Origin can set active link state with `aria-current`. ## Test Plan - `yarn workspace @lightsparkdev/site types --pretty false` - `yarn workspace @lightsparkdev/ui types` - `yarn workspace @lightsparkdev/site exec eslint src/uma-nage/components/NageSegmentedNav.tsx src/uma-nage/components/NageSegmentedNav.test.tsx src/uma-nage/developers/Developers.tsx` - `yarn workspace @lightsparkdev/ui exec eslint src/router.tsx` - `yarn workspace @lightsparkdev/site exec prettier --check src/uma-nage/components/NageSegmentedNav.tsx src/uma-nage/components/NageSegmentedNav.test.tsx src/uma-nage/developers/Developers.tsx` - `yarn workspace @lightsparkdev/ui exec prettier --check src/router.tsx` - `yarn workspace @lightsparkdev/site vitest run src/uma-nage/components/NageSegmentedNav.test.tsx` - `yarn workspace @lightsparkdev/site playwright test --list tests/21-nage-developers.spec.ts` - `git diff --check` Full local Playwright execution was not run because the available site server is using the dev proxy; the Nage Playwright README expects the hermetic minikube/Tilt backend, and the overlapping spec creates/deletes API tokens. Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor GitOrigin-RevId: 13aec1fe7ae69a6e16be9a22f08801dc9b721d49 --- packages/ui/src/router.tsx | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/router.tsx b/packages/ui/src/router.tsx index ced6d8ff9..44da051b6 100644 --- a/packages/ui/src/router.tsx +++ b/packages/ui/src/router.tsx @@ -4,7 +4,7 @@ import type { Theme } from "@emotion/react"; import type { Interpolation } from "@emotion/styled"; import styled from "@emotion/styled"; import { omit } from "lodash-es"; -import type { MouseEventHandler, ReactNode } from "react"; +import type { AnchorHTMLAttributes, MouseEventHandler, ReactNode } from "react"; import { forwardRef, useCallback } from "react"; import type { PathMatch } from "react-router-dom"; import { @@ -36,7 +36,19 @@ export type RouteHash = string | null; export type ExternalLink = string; -export type LinkProps = { +type LinkAnchorProps = Omit< + AnchorHTMLAttributes, + | "children" + | "className" + | "download" + | "href" + | "id" + | "onClick" + | "rel" + | "target" +>; + +export type LinkProps = LinkAnchorProps & { to?: NewRoutesType | undefined; id?: string | undefined; externalLink?: ExternalLink | undefined; @@ -111,6 +123,9 @@ export const LinkBase = forwardRef( blue = false, newTab: newTabProp, typography, + disabled: _disabled, + style, + ...anchorProps }, ref, ) => { @@ -154,13 +169,17 @@ export const LinkBase = forwardRef( return ( Date: Fri, 22 May 2026 16:41:14 -0700 Subject: [PATCH 32/56] [ui] Use built package imports in private apps (#27024) ## Reason The private apps were importing `@lightsparkdev/ui/src/...` directly to avoid an expensive UI package build. After the tsdown migration, the UI build is cheap enough that the apps should consume the built package surface instead. This makes the workspace dependency explicit and lets Turbo rerun app builds when the UI package build changes. ## Overview - Replace direct `@lightsparkdev/ui/src/...` imports in `site`, `ops`, `uma-bridge`, and the transitive `private-ui` source included by those apps with built `@lightsparkdev/ui/...` subpaths. - Add missing built barrel exports for `@lightsparkdev/ui/hooks`, `@lightsparkdev/ui/icons`, and `@lightsparkdev/ui/types`. - Remove `packages/ui/src` from the three app tsconfig includes. - Update the three app Turbo overrides to build and watch `@lightsparkdev/ui#build` explicitly while still avoiding `^build` until `private-ui` has a real build task. ## Test Plan - `cd js && ./node_modules/.bin/turbo run build --filter=@lightsparkdev/site --filter=@lightsparkdev/ops --filter=@lightsparkdev/uma-bridge` - `cd js && git -C .. diff --name-only -z -- 'js/**' | perl -0pe 's#js/##g' | xargs -0 ./node_modules/.bin/prettier --check` - `git diff --check origin/main...HEAD` - Verified `rg '@lightsparkdev/ui/src' js/apps js/packages/private/ui` returns no matches. GitOrigin-RevId: f5e9e50f992067c59517c244cf5266b82d032ad8 --- packages/eslint-config/package.json | 3 -- .../react-app-with-internal-ui.js | 26 ---------------- .../react-app-with-internal-ui.mjs | 30 ------------------- packages/ui/package.json | 12 ++++++++ 4 files changed, 12 insertions(+), 59 deletions(-) delete mode 100644 packages/eslint-config/react-app-with-internal-ui.js delete mode 100644 packages/eslint-config/react-app-with-internal-ui.mjs diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 428e0828f..8bf11b934 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -8,7 +8,6 @@ "base.mjs", "react-lib.mjs", "react-app.mjs", - "react-app-with-internal-ui.mjs", "constants/" ], "exports": { @@ -18,8 +17,6 @@ "./react-lib": "./react-lib.mjs", "./react-app.mjs": "./react-app.mjs", "./react-app": "./react-app.mjs", - "./react-app-with-internal-ui.mjs": "./react-app-with-internal-ui.mjs", - "./react-app-with-internal-ui": "./react-app-with-internal-ui.mjs", "./constants/react-restricted-imports.js": "./constants/react-restricted-imports.js", "./constants/react-restricted-imports": "./constants/react-restricted-imports.js" }, diff --git a/packages/eslint-config/react-app-with-internal-ui.js b/packages/eslint-config/react-app-with-internal-ui.js deleted file mode 100644 index d9dd50efb..000000000 --- a/packages/eslint-config/react-app-with-internal-ui.js +++ /dev/null @@ -1,26 +0,0 @@ -const reactAppRestrictedImports = - require("./constants/react-restricted-imports").reactAppRestrictedImports; - -module.exports = { - extends: ["./react-app"], - rules: { - "no-restricted-imports": [ - "error", - { - ...reactAppRestrictedImports, - patterns: [ - ...reactAppRestrictedImports.patterns, - { - group: [ - "@lightsparkdev/ui/**", - "!@lightsparkdev/ui/src", - "!@lightsparkdev/ui/src/**", - ], - message: - "This app can import directly from @lightsparkdev/ui/src to avoid requiring a build.", - }, - ], - }, - ], - }, -}; diff --git a/packages/eslint-config/react-app-with-internal-ui.mjs b/packages/eslint-config/react-app-with-internal-ui.mjs deleted file mode 100644 index b7a2187ae..000000000 --- a/packages/eslint-config/react-app-with-internal-ui.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import { createRequire } from 'node:module'; -import reactApp from './react-app.mjs'; - -const require = createRequire(import.meta.url); -const { reactAppRestrictedImports } = require('./constants/react-restricted-imports.js'); - -const appWithInternalUiRestricted = { - ...reactAppRestrictedImports, - patterns: [ - ...reactAppRestrictedImports.patterns, - { - group: [ - '@lightsparkdev/ui/**', - '!@lightsparkdev/ui/src', - '!@lightsparkdev/ui/src/**', - ], - message: - 'This app can import directly from @lightsparkdev/ui/src to avoid requiring a build.', - }, - ], -}; - -export default [ - ...reactApp, - { - rules: { - 'no-restricted-imports': ['error', appWithInternalUiRestricted], - }, - }, -]; diff --git a/packages/ui/package.json b/packages/ui/package.json index 1110698e3..fe6fbf360 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -23,10 +23,18 @@ "import": "./dist/components/typography/index.js", "require": "./dist/components/typography/index.cjs" }, + "./hooks": { + "import": "./dist/hooks/index.js", + "require": "./dist/hooks/index.cjs" + }, "./hooks/*": { "import": "./dist/hooks/*.js", "require": "./dist/hooks/*.cjs" }, + "./icons": { + "import": "./dist/icons/index.js", + "require": "./dist/icons/index.cjs" + }, "./icons/*": { "import": "./dist/icons/*.js", "require": "./dist/icons/*.cjs" @@ -35,6 +43,10 @@ "import": "./dist/styles/*.js", "require": "./dist/styles/*.cjs" }, + "./types": { + "import": "./dist/types/index.js", + "require": "./dist/types/index.cjs" + }, "./types/*": { "import": "./dist/types/*.js", "require": "./dist/types/*.cjs" From 1128b0ff8897114b9a99275153e68e0ea8946970 Mon Sep 17 00:00:00 2001 From: SOME1HING Date: Tue, 26 May 2026 01:45:58 +0530 Subject: [PATCH 33/56] Fix fail-open async UMA validation checks (#524) Fix async UMA validation checks by awaiting Promise results --- apps/examples/uma-vasp/src/ReceivingVasp.ts | 4 ++-- apps/examples/uma-vasp/src/SendingVasp.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/examples/uma-vasp/src/ReceivingVasp.ts b/apps/examples/uma-vasp/src/ReceivingVasp.ts index bc9d06b03..1fedc01d8 100644 --- a/apps/examples/uma-vasp/src/ReceivingVasp.ts +++ b/apps/examples/uma-vasp/src/ReceivingVasp.ts @@ -164,10 +164,10 @@ export default class ReceivingVasp { ); } if ( - !this.complianceService.shouldAcceptTransactionFromVasp( + !(await this.complianceService.shouldAcceptTransactionFromVasp( umaQuery.vaspDomain!, umaQuery.receiverAddress, - ) + )) ) { throw new uma.UmaError( "This user is not allowed to transact with this VASP.", diff --git a/apps/examples/uma-vasp/src/SendingVasp.ts b/apps/examples/uma-vasp/src/SendingVasp.ts index 3e1cfff20..dee8cc230 100644 --- a/apps/examples/uma-vasp/src/SendingVasp.ts +++ b/apps/examples/uma-vasp/src/SendingVasp.ts @@ -194,11 +194,11 @@ export default class SendingVasp { } if ( - !this.complianceService.shouldAcceptTransactionToVasp( + !(await this.complianceService.shouldAcceptTransactionToVasp( receivingVaspDomain, user.umaUserName, receiverUmaAddress, - ) + )) ) { throw new uma.UmaError( `Transaction not allowed to ${receiverUmaAddress}.`, @@ -481,12 +481,12 @@ export default class SendingVasp { amountValueMillisats / sendingCurrency.multiplier; if ( - !this.checkInternalLedgerBalance( + !(await this.checkInternalLedgerBalance( user.id, amountValueMillisats, sendingCurrencyAmount, sendingCurrencyCode, - ) + )) ) { throw new uma.UmaError( "Insufficient balance.", @@ -930,12 +930,12 @@ export default class SendingVasp { } if ( - !this.checkInternalLedgerBalance( + !(await this.checkInternalLedgerBalance( user.id, amountMsats, sendingCurrencyAmount, sendingCurrencyCode, - ) + )) ) { throw new uma.UmaError( "Insufficient balance.", From 32792cacc99e630921e3ee1f1419953e27acde2c Mon Sep 17 00:00:00 2001 From: Corey Martin Date: Tue, 26 May 2026 16:45:25 -0700 Subject: [PATCH 34/56] [js] Update form-data resolution (#27827) ## Summary - Update the root `form-data` resolution to `4.0.5`. - Refresh `js/yarn.lock` so all `form-data` requesters resolve consistently. ## Related advisories - [CVE-2025-7783](https://www.cve.org/CVERecord?id=CVE-2025-7783) / [GHSA-fjxv-7rqg-78g4](https://github.com/advisories/GHSA-fjxv-7rqg-78g4) ## Testing - `yarn why form-data` - `yarn install --immutable` - `yarn deps:check` ## Notes - `yarn install` reports existing peer dependency warnings unrelated to this change. GitOrigin-RevId: 535cb0122b2fb9e5429d871fa4b19d605e4c3ba9 --- apps/examples/uma-vasp/src/ReceivingVasp.ts | 4 ++-- apps/examples/uma-vasp/src/SendingVasp.ts | 12 ++++++------ package.json | 3 ++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/examples/uma-vasp/src/ReceivingVasp.ts b/apps/examples/uma-vasp/src/ReceivingVasp.ts index 1fedc01d8..bc9d06b03 100644 --- a/apps/examples/uma-vasp/src/ReceivingVasp.ts +++ b/apps/examples/uma-vasp/src/ReceivingVasp.ts @@ -164,10 +164,10 @@ export default class ReceivingVasp { ); } if ( - !(await this.complianceService.shouldAcceptTransactionFromVasp( + !this.complianceService.shouldAcceptTransactionFromVasp( umaQuery.vaspDomain!, umaQuery.receiverAddress, - )) + ) ) { throw new uma.UmaError( "This user is not allowed to transact with this VASP.", diff --git a/apps/examples/uma-vasp/src/SendingVasp.ts b/apps/examples/uma-vasp/src/SendingVasp.ts index dee8cc230..3e1cfff20 100644 --- a/apps/examples/uma-vasp/src/SendingVasp.ts +++ b/apps/examples/uma-vasp/src/SendingVasp.ts @@ -194,11 +194,11 @@ export default class SendingVasp { } if ( - !(await this.complianceService.shouldAcceptTransactionToVasp( + !this.complianceService.shouldAcceptTransactionToVasp( receivingVaspDomain, user.umaUserName, receiverUmaAddress, - )) + ) ) { throw new uma.UmaError( `Transaction not allowed to ${receiverUmaAddress}.`, @@ -481,12 +481,12 @@ export default class SendingVasp { amountValueMillisats / sendingCurrency.multiplier; if ( - !(await this.checkInternalLedgerBalance( + !this.checkInternalLedgerBalance( user.id, amountValueMillisats, sendingCurrencyAmount, sendingCurrencyCode, - )) + ) ) { throw new uma.UmaError( "Insufficient balance.", @@ -930,12 +930,12 @@ export default class SendingVasp { } if ( - !(await this.checkInternalLedgerBalance( + !this.checkInternalLedgerBalance( user.id, amountMsats, sendingCurrencyAmount, sendingCurrencyCode, - )) + ) ) { throw new uma.UmaError( "Insufficient balance.", diff --git a/package.json b/package.json index b4482474d..781df2ed3 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,8 @@ } }, "resolutions": { - "axios": "1.7.7" + "axios": "1.7.7", + "form-data": "4.0.5" }, "engines": { "node": ">=18" From 5bc0a0dfe175a2c6dc77f3306834b8a73aa532ef Mon Sep 17 00:00:00 2001 From: Lightspark Eng Date: Tue, 26 May 2026 23:56:04 +0000 Subject: [PATCH 35/56] CI update lock file for PR --- yarn.lock | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index 8defcce9b..5af748c75 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10554,14 +10554,16 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.0": - version: 4.0.0 - resolution: "form-data@npm:4.0.0" +"form-data@npm:4.0.5": + version: 4.0.5 + resolution: "form-data@npm:4.0.5" dependencies: asynckit: "npm:^0.4.0" combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.2" mime-types: "npm:^2.1.12" - checksum: 10/7264aa760a8cf09482816d8300f1b6e2423de1b02bba612a136857413fdc96d7178298ced106817655facc6b89036c6e12ae31c9eb5bdc16aabf502ae8a5d805 + checksum: 10/52ecd6e927c8c4e215e68a7ad5e0f7c1031397439672fd9741654b4a94722c4182e74cc815b225dcb5be3f4180f36428f67c6dd39eaa98af0dcfdd26c00c19cd languageName: node linkType: hard From dfc6a8c754972e6414f26c0a77ab577e427c4f62 Mon Sep 17 00:00:00 2001 From: Corey Martin Date: Tue, 26 May 2026 22:32:16 -0700 Subject: [PATCH 36/56] [js] Update vite dependency (#27876) ## Summary - Updates direct Vite dev dependencies to `^8.0.14`. - Refreshes transitive Vite lockfile entries within their existing ranges.
Related advisories - [CVE-2026-39363](https://www.cve.org/CVERecord?id=CVE-2026-39363) / [GHSA-p9ff-h696-f583](https://github.com/advisories/GHSA-p9ff-h696-f583) - [CVE-2026-39364](https://www.cve.org/CVERecord?id=CVE-2026-39364) / [GHSA-v2wj-q39q-566r](https://github.com/advisories/GHSA-v2wj-q39q-566r)
## Test plan - `npm view vite version dist-tags dependencies peerDependencies --json` - `npm view vite@6 version --json` - `npm view vite@7 version --json` - `yarn install --immutable` - `yarn deps:check` - `yarn why vite` - `yarn why vite | rg "vite@npm:(6\.4\.1|7\.3\.1|8\.0\.[0-4])([^0-9]|$)" && exit 1 || true` - `git diff --check` - `yarn turbo run build --filter=@lightsparkdev/vite... --filter=@lightsparkdev/site... --filter=@lightsparkdev/ops... --filter=@lightsparkdev/uma-bridge... --filter=@lightsparkdev/storybook... --filter=@lightsparkdev/origin...` GitOrigin-RevId: 2a900a358c28334c55da6586fe4aec9bef5143e8 --- apps/examples/grid-global-accounts-example-app/package.json | 2 +- apps/examples/oauth-app/package.json | 2 +- apps/examples/ui-test-app/package.json | 2 +- packages/origin/package.json | 2 +- packages/vite/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/examples/grid-global-accounts-example-app/package.json b/apps/examples/grid-global-accounts-example-app/package.json index 3ffe1a730..81c26423b 100644 --- a/apps/examples/grid-global-accounts-example-app/package.json +++ b/apps/examples/grid-global-accounts-example-app/package.json @@ -10,7 +10,7 @@ }, "devDependencies": { "typescript": "^5.6.2", - "vite": "^8.0.3" + "vite": "^8.0.14" }, "dependencies": { "@turnkey/api-key-stamper": "^0.6.5", diff --git a/apps/examples/oauth-app/package.json b/apps/examples/oauth-app/package.json index 41720ff4a..715591327 100644 --- a/apps/examples/oauth-app/package.json +++ b/apps/examples/oauth-app/package.json @@ -28,7 +28,7 @@ "prettier-plugin-organize-imports": "^3.2.4", "tsc-absolute": "^1.0.1", "typescript": "^5.6.2", - "vite": "^8.0.3" + "vite": "^8.0.14" }, "scripts": { "start": "yarn vite", diff --git a/apps/examples/ui-test-app/package.json b/apps/examples/ui-test-app/package.json index c2e5b6729..0f1990739 100644 --- a/apps/examples/ui-test-app/package.json +++ b/apps/examples/ui-test-app/package.json @@ -58,7 +58,7 @@ "ts-jest": "^29.1.1", "tsc-absolute": "^1.0.1", "typescript": "^5.6.2", - "vite": "^8.0.3" + "vite": "^8.0.14" }, "madge": { "detectiveOptions": { diff --git a/packages/origin/package.json b/packages/origin/package.json index 4fd6c9277..12bae4d99 100644 --- a/packages/origin/package.json +++ b/packages/origin/package.json @@ -109,7 +109,7 @@ "stylelint": "^17.1.1", "stylelint-config-standard-scss": "^17.0.0", "typescript": "^5.6.2", - "vite": "^8.0.3", + "vite": "^8.0.14", "vitest": "^3.1.4" }, "engines": { diff --git a/packages/vite/package.json b/packages/vite/package.json index 46a981d99..f82ca99f2 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -6,7 +6,7 @@ "type": "module", "dependencies": { "rollup-plugin-visualizer": "^7.0.1", - "vite": "^8.0.3", + "vite": "^8.0.14", "vite-plugin-svgr": "^4.5.0" }, "devDependencies": { From 9c2a3f104114c754ef59499575c6ac1f2e15cbb2 Mon Sep 17 00:00:00 2001 From: Corey Martin Date: Tue, 26 May 2026 22:34:08 -0700 Subject: [PATCH 37/56] [origin] Update fast-uri dependency (#27874) ## Summary - Updates `@lightsparkdev/origin`'s Ajv dependency to `^8.20.0`. - Refreshes the transitive `fast-uri` lockfile entry to `3.1.2`.
Related advisories - [CVE-2026-6322](https://www.cve.org/CVERecord?id=CVE-2026-6322) / [GHSA-v39h-62p7-jpjc](https://github.com/advisories/GHSA-v39h-62p7-jpjc) - [CVE-2026-6321](https://www.cve.org/CVERecord?id=CVE-2026-6321) / [GHSA-q3j6-qgpj-74h6](https://github.com/advisories/GHSA-q3j6-qgpj-74h6)
## Test plan - `yarn install` - `yarn install --immutable` - `yarn deps:check` - `yarn why fast-uri` - `yarn why ajv` - `yarn why fast-uri | rg "fast-uri@npm:3\.(0|1\.[01])" && exit 1 || true` - `git diff --check` - `yarn turbo run package:checks --filter=@lightsparkdev/origin` GitOrigin-RevId: 7f7a5cfc8c516f953450091a91b9a0518638f97a --- packages/origin/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/origin/package.json b/packages/origin/package.json index 12bae4d99..d16186eac 100644 --- a/packages/origin/package.json +++ b/packages/origin/package.json @@ -63,7 +63,7 @@ "@base-ui/react": "^1.1.0", "@base-ui/utils": "^0.2.3", "@tanstack/react-table": "^8.21.3", - "ajv": "^8.18.0", + "ajv": "^8.20.0", "clsx": "^2.1.1" }, "peerDependencies": { From ae2b5844eb266a72e7dcac0be6fab9400fc743cf Mon Sep 17 00:00:00 2001 From: Corey Martin Date: Tue, 26 May 2026 22:34:58 -0700 Subject: [PATCH 38/56] [js] Update axios dependency (#27873) ## Reason Refreshes the Axios dependency used by the JS app workspaces. ## Overview Updates `axios` to `1.16.1` in `ops`, `site`, and `uma-bridge`. This also removes the stale root Axios resolution, so the normal dependency ranges now resolve the shared Axios entry for the direct apps and existing transitive parents. `follow-redirects` resolves to `1.16.0` through the updated Axios graph.
Related advisories - [CVE-2026-42044](https://www.cve.org/CVERecord?id=CVE-2026-42044) - [GHSA-3w6x-2g7m-8v23](https://github.com/advisories/GHSA-3w6x-2g7m-8v23) - [CVE-2026-42037](https://www.cve.org/CVERecord?id=CVE-2026-42037) - [GHSA-445q-vr5w-6q77](https://github.com/advisories/GHSA-445q-vr5w-6q77) - [CVE-2026-42034](https://www.cve.org/CVERecord?id=CVE-2026-42034) - [GHSA-5c9x-8gcm-mpgx](https://github.com/advisories/GHSA-5c9x-8gcm-mpgx) - [CVE-2026-42039](https://www.cve.org/CVERecord?id=CVE-2026-42039) - [GHSA-62hf-57xw-28j9](https://github.com/advisories/GHSA-62hf-57xw-28j9) - [CVE-2026-42035](https://www.cve.org/CVERecord?id=CVE-2026-42035) - [GHSA-6chq-wfr3-2hj9](https://github.com/advisories/GHSA-6chq-wfr3-2hj9) - [CVE-2026-42038](https://www.cve.org/CVERecord?id=CVE-2026-42038) - [GHSA-m7pr-hjqh-92cm](https://github.com/advisories/GHSA-m7pr-hjqh-92cm) - [CVE-2026-42033](https://www.cve.org/CVERecord?id=CVE-2026-42033) - [GHSA-pf86-5x62-jrwf](https://github.com/advisories/GHSA-pf86-5x62-jrwf) - [CVE-2026-42043](https://www.cve.org/CVERecord?id=CVE-2026-42043) - [GHSA-pmwg-cvhr-8vh7](https://github.com/advisories/GHSA-pmwg-cvhr-8vh7) - [CVE-2026-42264](https://www.cve.org/CVERecord?id=CVE-2026-42264) - [GHSA-q8qp-cvcw-x6jj](https://github.com/advisories/GHSA-q8qp-cvcw-x6jj) - [CVE-2026-42036](https://www.cve.org/CVERecord?id=CVE-2026-42036) - [GHSA-vf2m-468p-8v99](https://github.com/advisories/GHSA-vf2m-468p-8v99) - [CVE-2026-42040](https://www.cve.org/CVERecord?id=CVE-2026-42040) - [GHSA-xhjh-pmcv-23jw](https://github.com/advisories/GHSA-xhjh-pmcv-23jw) - [CVE-2026-42041](https://www.cve.org/CVERecord?id=CVE-2026-42041) - [GHSA-w9j2-pvgh-6h63](https://github.com/advisories/GHSA-w9j2-pvgh-6h63) - [CVE-2026-42042](https://www.cve.org/CVERecord?id=CVE-2026-42042) - [GHSA-xx6v-rp6x-q39c](https://github.com/advisories/GHSA-xx6v-rp6x-q39c) - [CVE-2025-27152](https://www.cve.org/CVERecord?id=CVE-2025-27152) - [GHSA-jr5f-v2jv-69x6](https://github.com/advisories/GHSA-jr5f-v2jv-69x6) - [CVE-2026-25639](https://www.cve.org/CVERecord?id=CVE-2026-25639) - [GHSA-43fc-jf86-j433](https://github.com/advisories/GHSA-43fc-jf86-j433) - [CVE-2025-62718](https://www.cve.org/CVERecord?id=CVE-2025-62718) - [GHSA-3p68-rc4w-qgx5](https://github.com/advisories/GHSA-3p68-rc4w-qgx5) - [CVE-2026-40175](https://www.cve.org/CVERecord?id=CVE-2026-40175) - [GHSA-fvcv-3m26-pcqx](https://github.com/advisories/GHSA-fvcv-3m26-pcqx) - [CVE-2025-58754](https://www.cve.org/CVERecord?id=CVE-2025-58754) - [GHSA-4hjh-wcwx-xvwj](https://github.com/advisories/GHSA-4hjh-wcwx-xvwj)
Related advisories - [CVE-2026-42035](https://www.cve.org/CVERecord?id=CVE-2026-42035) / [GHSA-6chq-wfr3-2hj9](https://github.com/advisories/GHSA-6chq-wfr3-2hj9) - [CVE-2026-42033](https://www.cve.org/CVERecord?id=CVE-2026-42033) / [GHSA-pf86-5x62-jrwf](https://github.com/advisories/GHSA-pf86-5x62-jrwf) - [CVE-2026-42043](https://www.cve.org/CVERecord?id=CVE-2026-42043) / [GHSA-pmwg-cvhr-8vh7](https://github.com/advisories/GHSA-pmwg-cvhr-8vh7) - [CVE-2026-42264](https://www.cve.org/CVERecord?id=CVE-2026-42264) / [GHSA-q8qp-cvcw-x6jj](https://github.com/advisories/GHSA-q8qp-cvcw-x6jj) - [CVE-2025-27152](https://www.cve.org/CVERecord?id=CVE-2025-27152) / [GHSA-jr5f-v2jv-69x6](https://github.com/advisories/GHSA-jr5f-v2jv-69x6) - [CVE-2026-25639](https://www.cve.org/CVERecord?id=CVE-2026-25639) / [GHSA-43fc-jf86-j433](https://github.com/advisories/GHSA-43fc-jf86-j433) - [CVE-2025-58754](https://www.cve.org/CVERecord?id=CVE-2025-58754) / [GHSA-4hjh-wcwx-xvwj](https://github.com/advisories/GHSA-4hjh-wcwx-xvwj)
## Test plan - `npm view axios version dependencies peerDependencies --json` - `yarn why axios` - `yarn why follow-redirects` - `yarn install --immutable` - `yarn deps:check` - `git diff --check` - `yarn why axios | rg 'axios@npm:1\\.(?:[0-9]|1[0-5])\\.' || true` - `yarn why follow-redirects | rg 'follow-redirects@npm:1\\.15\\.' || true` - `yarn turbo run build --filter=@lightsparkdev/site... --filter=@lightsparkdev/uma-bridge... --filter=@lightsparkdev/ops...` - pre-commit hook: `yarn install`, `yarn format` GitOrigin-RevId: d4755f63b2de034de975cb3b892685026a74b490 --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 781df2ed3..1b97e2a27 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,6 @@ } }, "resolutions": { - "axios": "1.7.7", "form-data": "4.0.5" }, "engines": { From 1508dc11c0114c814682c036f33e103eb04e567b Mon Sep 17 00:00:00 2001 From: Lightspark Eng Date: Wed, 27 May 2026 05:40:18 +0000 Subject: [PATCH 39/56] CI update lock file for PR --- yarn.lock | 353 +++++++++++++++++++++++++++--------------------------- 1 file changed, 174 insertions(+), 179 deletions(-) diff --git a/yarn.lock b/yarn.lock index 5af748c75..e88e77032 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1258,16 +1258,6 @@ __metadata: languageName: node linkType: hard -"@emnapi/core@npm:^1.7.1": - version: 1.9.1 - resolution: "@emnapi/core@npm:1.9.1" - dependencies: - "@emnapi/wasi-threads": "npm:1.2.0" - tslib: "npm:^2.4.0" - checksum: 10/c44cfe471702b43306b84d0f4f2f1506dac0065dbd73dc5a41bd99a2c39802ca7e2d7ebfbfae8997468d1ff0420603596bf35b19eabd5951bad1eb630d2d4574 - languageName: node - linkType: hard - "@emnapi/runtime@npm:1.10.0": version: 1.10.0 resolution: "@emnapi/runtime@npm:1.10.0" @@ -1277,24 +1267,6 @@ __metadata: languageName: node linkType: hard -"@emnapi/runtime@npm:^1.7.1": - version: 1.9.1 - resolution: "@emnapi/runtime@npm:1.9.1" - dependencies: - tslib: "npm:^2.4.0" - checksum: 10/337767fa44ec1f6277494342664be8773f16aad4086e9e49423a9f06c5eee7495e2e1b0b50dcd764c5a5cc4c15c9d80c13fba2da6763a97c06a48115cd7ccd14 - languageName: node - linkType: hard - -"@emnapi/wasi-threads@npm:1.2.0": - version: 1.2.0 - resolution: "@emnapi/wasi-threads@npm:1.2.0" - dependencies: - tslib: "npm:^2.4.0" - checksum: 10/c8e48c7200530744dc58170d2e25933b61433e4a0c50b4f192f5d8d4b065c7023dbfc48dac0afadbc29bd239013f2ae454c6e54e0ca6e8248402bf95c9e77e22 - languageName: node - linkType: hard - "@emnapi/wasi-threads@npm:1.2.1": version: 1.2.1 resolution: "@emnapi/wasi-threads@npm:1.2.1" @@ -2896,7 +2868,7 @@ __metadata: "@turnkey/api-key-stamper": "npm:^0.6.5" "@turnkey/crypto": "npm:^2.8.14" typescript: "npm:^5.6.2" - vite: "npm:^8.0.3" + vite: "npm:^8.0.14" languageName: unknown linkType: soft @@ -3020,7 +2992,7 @@ __metadata: react-router-dom: "npm:6.11.2" tsc-absolute: "npm:^1.0.1" typescript: "npm:^5.6.2" - vite: "npm:^8.0.3" + vite: "npm:^8.0.14" web-vitals: "npm:^3.3.0" languageName: unknown linkType: soft @@ -3073,7 +3045,7 @@ __metadata: "@types/react": "npm:^18.2.12" "@types/react-dom": "npm:^18.0.0" "@vitejs/plugin-react": "npm:^5.2.0" - ajv: "npm:^8.18.0" + ajv: "npm:^8.20.0" clsx: "npm:^2.1.1" dotenv: "npm:^16.3.1" eslint: "npm:^9.0.0" @@ -3090,7 +3062,7 @@ __metadata: stylelint: "npm:^17.1.1" stylelint-config-standard-scss: "npm:^17.0.0" typescript: "npm:^5.6.2" - vite: "npm:^8.0.3" + vite: "npm:^8.0.14" vitest: "npm:^3.1.4" peerDependencies: next: ">=13" @@ -3177,7 +3149,7 @@ __metadata: ts-jest: "npm:^29.1.1" tsc-absolute: "npm:^1.0.1" typescript: "npm:^5.6.2" - vite: "npm:^8.0.3" + vite: "npm:^8.0.14" languageName: unknown linkType: soft @@ -3307,7 +3279,7 @@ __metadata: dependencies: "@vitejs/plugin-react": "npm:^5.2.0" rollup-plugin-visualizer: "npm:^7.0.1" - vite: "npm:^8.0.3" + vite: "npm:^8.0.14" vite-plugin-svgr: "npm:^4.5.0" peerDependencies: "@vitejs/plugin-react": ">=5" @@ -3453,17 +3425,6 @@ __metadata: languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^1.1.1": - version: 1.1.1 - resolution: "@napi-rs/wasm-runtime@npm:1.1.1" - dependencies: - "@emnapi/core": "npm:^1.7.1" - "@emnapi/runtime": "npm:^1.7.1" - "@tybys/wasm-util": "npm:^0.10.1" - checksum: 10/080e7f2aefb84e09884d21c650a2cbafdf25bfd2634693791b27e36eec0ddaa3c1656a943f8c913ac75879a0b04e68f8a827897ee655ab54a93169accf05b194 - languageName: node - linkType: hard - "@napi-rs/wasm-runtime@npm:^1.1.4": version: 1.1.4 resolution: "@napi-rs/wasm-runtime@npm:1.1.4" @@ -3973,13 +3934,6 @@ __metadata: languageName: node linkType: hard -"@oxc-project/types@npm:=0.122.0": - version: 0.122.0 - resolution: "@oxc-project/types@npm:0.122.0" - checksum: 10/2b33895c7701a595d10b9c7b0927222954becc4c6cbde7a7b582e9524828937368baacba1cbb6e3c33bc9a18e0a35435ffff6c53f511762ae872d55d3e993a8c - languageName: node - linkType: hard - "@oxc-project/types@npm:=0.127.0": version: 0.127.0 resolution: "@oxc-project/types@npm:0.127.0" @@ -3987,6 +3941,13 @@ __metadata: languageName: node linkType: hard +"@oxc-project/types@npm:=0.132.0": + version: 0.132.0 + resolution: "@oxc-project/types@npm:0.132.0" + checksum: 10/e0694a3c24746006ad774a1cab34efac3ccad5b519234063bcde17e9afe3475680749357e9f90164a222326414cb9510da1b8da350edc0cd35612fd05147c218 + languageName: node + linkType: hard + "@parcel/watcher-android-arm64@npm:2.5.6": version: 2.5.6 resolution: "@parcel/watcher-android-arm64@npm:2.5.6" @@ -4356,13 +4317,6 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-android-arm64@npm:1.0.0-rc.12": - version: 1.0.0-rc.12 - resolution: "@rolldown/binding-android-arm64@npm:1.0.0-rc.12" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - "@rolldown/binding-android-arm64@npm:1.0.0-rc.17": version: 1.0.0-rc.17 resolution: "@rolldown/binding-android-arm64@npm:1.0.0-rc.17" @@ -4370,10 +4324,10 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-darwin-arm64@npm:1.0.0-rc.12": - version: 1.0.0-rc.12 - resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.12" - conditions: os=darwin & cpu=arm64 +"@rolldown/binding-android-arm64@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-android-arm64@npm:1.0.2" + conditions: os=android & cpu=arm64 languageName: node linkType: hard @@ -4384,10 +4338,10 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-darwin-x64@npm:1.0.0-rc.12": - version: 1.0.0-rc.12 - resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-rc.12" - conditions: os=darwin & cpu=x64 +"@rolldown/binding-darwin-arm64@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-darwin-arm64@npm:1.0.2" + conditions: os=darwin & cpu=arm64 languageName: node linkType: hard @@ -4398,10 +4352,10 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-freebsd-x64@npm:1.0.0-rc.12": - version: 1.0.0-rc.12 - resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.12" - conditions: os=freebsd & cpu=x64 +"@rolldown/binding-darwin-x64@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-darwin-x64@npm:1.0.2" + conditions: os=darwin & cpu=x64 languageName: node linkType: hard @@ -4412,10 +4366,10 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.12": - version: 1.0.0-rc.12 - resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.12" - conditions: os=linux & cpu=arm +"@rolldown/binding-freebsd-x64@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-freebsd-x64@npm:1.0.2" + conditions: os=freebsd & cpu=x64 languageName: node linkType: hard @@ -4426,10 +4380,10 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.12": - version: 1.0.0-rc.12 - resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.12" - conditions: os=linux & cpu=arm64 & libc=glibc +"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.2" + conditions: os=linux & cpu=arm languageName: node linkType: hard @@ -4440,10 +4394,10 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.12": - version: 1.0.0-rc.12 - resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.12" - conditions: os=linux & cpu=arm64 & libc=musl +"@rolldown/binding-linux-arm64-gnu@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.2" + conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard @@ -4454,10 +4408,10 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.12": - version: 1.0.0-rc.12 - resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.12" - conditions: os=linux & cpu=ppc64 & libc=glibc +"@rolldown/binding-linux-arm64-musl@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.2" + conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard @@ -4468,10 +4422,10 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.12": - version: 1.0.0-rc.12 - resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.12" - conditions: os=linux & cpu=s390x & libc=glibc +"@rolldown/binding-linux-ppc64-gnu@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.2" + conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard @@ -4482,10 +4436,10 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.12": - version: 1.0.0-rc.12 - resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.12" - conditions: os=linux & cpu=x64 & libc=glibc +"@rolldown/binding-linux-s390x-gnu@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.2" + conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard @@ -4496,10 +4450,10 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.12": - version: 1.0.0-rc.12 - resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.12" - conditions: os=linux & cpu=x64 & libc=musl +"@rolldown/binding-linux-x64-gnu@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.2" + conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard @@ -4510,10 +4464,10 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.12": - version: 1.0.0-rc.12 - resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.12" - conditions: os=openharmony & cpu=arm64 +"@rolldown/binding-linux-x64-musl@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.2" + conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard @@ -4524,12 +4478,10 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.12": - version: 1.0.0-rc.12 - resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.12" - dependencies: - "@napi-rs/wasm-runtime": "npm:^1.1.1" - conditions: cpu=wasm32 +"@rolldown/binding-openharmony-arm64@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.2" + conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard @@ -4544,10 +4496,14 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.12": - version: 1.0.0-rc.12 - resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.12" - conditions: os=win32 & cpu=arm64 +"@rolldown/binding-wasm32-wasi@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.2" + dependencies: + "@emnapi/core": "npm:1.10.0" + "@emnapi/runtime": "npm:1.10.0" + "@napi-rs/wasm-runtime": "npm:^1.1.4" + conditions: cpu=wasm32 languageName: node linkType: hard @@ -4558,10 +4514,10 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.12": - version: 1.0.0-rc.12 - resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.12" - conditions: os=win32 & cpu=x64 +"@rolldown/binding-win32-arm64-msvc@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.2" + conditions: os=win32 & cpu=arm64 languageName: node linkType: hard @@ -4572,6 +4528,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-win32-x64-msvc@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rolldown/pluginutils@npm:1.0.0-beta.27": version: 1.0.0-beta.27 resolution: "@rolldown/pluginutils@npm:1.0.0-beta.27" @@ -4579,13 +4542,6 @@ __metadata: languageName: node linkType: hard -"@rolldown/pluginutils@npm:1.0.0-rc.12": - version: 1.0.0-rc.12 - resolution: "@rolldown/pluginutils@npm:1.0.0-rc.12" - checksum: 10/6ce1601849b3095a2b6e57074c1f8a661eba67ebf65cf9afdf894d903302318247ddb69ab6cbc621e7f582408af301ea0523ed59ddb9a4ef3ea97f3d7002683e - languageName: node - linkType: hard - "@rolldown/pluginutils@npm:1.0.0-rc.17": version: 1.0.0-rc.17 resolution: "@rolldown/pluginutils@npm:1.0.0-rc.17" @@ -4600,6 +4556,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/pluginutils@npm:^1.0.0": + version: 1.0.1 + resolution: "@rolldown/pluginutils@npm:1.0.1" + checksum: 10/4e95cf9ce23d75e5aa03ea0249cd86f7d1e21f83fbf6f8520e4edd8a251ba1b82c4ba9bc13cd24b6c4661daec6225b06e6d35c64c604e731b230b2a49af47d05 + languageName: node + linkType: hard + "@rollup/plugin-url@npm:^8.0.2": version: 8.0.2 resolution: "@rollup/plugin-url@npm:8.0.2" @@ -6676,7 +6639,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.0.1, ajv@npm:^8.18.0": +"ajv@npm:^8.0.1": version: 8.18.0 resolution: "ajv@npm:8.18.0" dependencies: @@ -6688,6 +6651,18 @@ __metadata: languageName: node linkType: hard +"ajv@npm:^8.20.0": + version: 8.20.0 + resolution: "ajv@npm:8.20.0" + dependencies: + fast-deep-equal: "npm:^3.1.3" + fast-uri: "npm:^3.0.1" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + checksum: 10/5ce59c0537f4c2aca9a758b412659ec70acb4d5dde971c10ecf21d2e3d799f99acdb4a08e1f5fb2e067c8542930398aae793bb996bb07d3feb81dae22fe2ada9 + languageName: node + linkType: hard + "ajv@npm:~8.13.0": version: 8.13.0 resolution: "ajv@npm:8.13.0" @@ -14065,6 +14040,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:^3.3.12": + version: 3.3.12 + resolution: "nanoid@npm:3.3.12" + bin: + nanoid: bin/nanoid.cjs + checksum: 10/6eec280694e2088d18fb802b1e3bfc4578e27b665b7ecfbe36c7356612fea2f814277056e671e2a1529dff551588a652efdc0bfa39f8a3185bc2247be311872e + languageName: node + linkType: hard + "nanoid@npm:^3.3.6": version: 3.3.7 resolution: "nanoid@npm:3.3.7" @@ -15088,6 +15072,17 @@ __metadata: languageName: node linkType: hard +"postcss@npm:^8.5.15": + version: 8.5.15 + resolution: "postcss@npm:8.5.15" + dependencies: + nanoid: "npm:^3.3.12" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10/d02ad19eb1e0fa53a1229ee6d53807eb88f903f2b9a8cac66993367f3ac7dd3b97238c783a54ccbf4145f82f6ca9a5cbd58f089846285d759c8a3259fbea8318 + languageName: node + linkType: hard + "postcss@npm:^8.5.3, postcss@npm:^8.5.6, postcss@npm:^8.5.8": version: 8.5.8 resolution: "postcss@npm:8.5.8" @@ -16144,27 +16139,27 @@ __metadata: languageName: node linkType: hard -"rolldown@npm:1.0.0-rc.12": - version: 1.0.0-rc.12 - resolution: "rolldown@npm:1.0.0-rc.12" - dependencies: - "@oxc-project/types": "npm:=0.122.0" - "@rolldown/binding-android-arm64": "npm:1.0.0-rc.12" - "@rolldown/binding-darwin-arm64": "npm:1.0.0-rc.12" - "@rolldown/binding-darwin-x64": "npm:1.0.0-rc.12" - "@rolldown/binding-freebsd-x64": "npm:1.0.0-rc.12" - "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-rc.12" - "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-rc.12" - "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-rc.12" - "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.0-rc.12" - "@rolldown/binding-linux-s390x-gnu": "npm:1.0.0-rc.12" - "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-rc.12" - "@rolldown/binding-linux-x64-musl": "npm:1.0.0-rc.12" - "@rolldown/binding-openharmony-arm64": "npm:1.0.0-rc.12" - "@rolldown/binding-wasm32-wasi": "npm:1.0.0-rc.12" - "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-rc.12" - "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-rc.12" - "@rolldown/pluginutils": "npm:1.0.0-rc.12" +"rolldown@npm:1.0.0-rc.17": + version: 1.0.0-rc.17 + resolution: "rolldown@npm:1.0.0-rc.17" + dependencies: + "@oxc-project/types": "npm:=0.127.0" + "@rolldown/binding-android-arm64": "npm:1.0.0-rc.17" + "@rolldown/binding-darwin-arm64": "npm:1.0.0-rc.17" + "@rolldown/binding-darwin-x64": "npm:1.0.0-rc.17" + "@rolldown/binding-freebsd-x64": "npm:1.0.0-rc.17" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-rc.17" + "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-rc.17" + "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-rc.17" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.0-rc.17" + "@rolldown/binding-linux-s390x-gnu": "npm:1.0.0-rc.17" + "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-rc.17" + "@rolldown/binding-linux-x64-musl": "npm:1.0.0-rc.17" + "@rolldown/binding-openharmony-arm64": "npm:1.0.0-rc.17" + "@rolldown/binding-wasm32-wasi": "npm:1.0.0-rc.17" + "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-rc.17" + "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-rc.17" + "@rolldown/pluginutils": "npm:1.0.0-rc.17" dependenciesMeta: "@rolldown/binding-android-arm64": optional: true @@ -16198,31 +16193,31 @@ __metadata: optional: true bin: rolldown: bin/cli.mjs - checksum: 10/b8cc0d9df80b495a57b63d69a16a5566c600162046edd407f335a6d27e5b6618a2d88d63e82c4e77a1447d18edcc6900696e041c33236ef38ab51d33cf5da2fe + checksum: 10/5e7415a7cb732c4f7168ab6dcc841ed9ec4ad614058294a53d94821a762c274a69b009e41e9c8e4983a059907f02d462030a36b42543c0f41ce702fcd68d10d5 languageName: node linkType: hard -"rolldown@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "rolldown@npm:1.0.0-rc.17" - dependencies: - "@oxc-project/types": "npm:=0.127.0" - "@rolldown/binding-android-arm64": "npm:1.0.0-rc.17" - "@rolldown/binding-darwin-arm64": "npm:1.0.0-rc.17" - "@rolldown/binding-darwin-x64": "npm:1.0.0-rc.17" - "@rolldown/binding-freebsd-x64": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-s390x-gnu": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-x64-musl": "npm:1.0.0-rc.17" - "@rolldown/binding-openharmony-arm64": "npm:1.0.0-rc.17" - "@rolldown/binding-wasm32-wasi": "npm:1.0.0-rc.17" - "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-rc.17" - "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-rc.17" - "@rolldown/pluginutils": "npm:1.0.0-rc.17" +"rolldown@npm:1.0.2": + version: 1.0.2 + resolution: "rolldown@npm:1.0.2" + dependencies: + "@oxc-project/types": "npm:=0.132.0" + "@rolldown/binding-android-arm64": "npm:1.0.2" + "@rolldown/binding-darwin-arm64": "npm:1.0.2" + "@rolldown/binding-darwin-x64": "npm:1.0.2" + "@rolldown/binding-freebsd-x64": "npm:1.0.2" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.2" + "@rolldown/binding-linux-arm64-gnu": "npm:1.0.2" + "@rolldown/binding-linux-arm64-musl": "npm:1.0.2" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.2" + "@rolldown/binding-linux-s390x-gnu": "npm:1.0.2" + "@rolldown/binding-linux-x64-gnu": "npm:1.0.2" + "@rolldown/binding-linux-x64-musl": "npm:1.0.2" + "@rolldown/binding-openharmony-arm64": "npm:1.0.2" + "@rolldown/binding-wasm32-wasi": "npm:1.0.2" + "@rolldown/binding-win32-arm64-msvc": "npm:1.0.2" + "@rolldown/binding-win32-x64-msvc": "npm:1.0.2" + "@rolldown/pluginutils": "npm:^1.0.0" dependenciesMeta: "@rolldown/binding-android-arm64": optional: true @@ -16255,8 +16250,8 @@ __metadata: "@rolldown/binding-win32-x64-msvc": optional: true bin: - rolldown: bin/cli.mjs - checksum: 10/5e7415a7cb732c4f7168ab6dcc841ed9ec4ad614058294a53d94821a762c274a69b009e41e9c8e4983a059907f02d462030a36b42543c0f41ce702fcd68d10d5 + rolldown: ./bin/cli.mjs + checksum: 10/2e51f0b2332eef4001262dad360886ca11376558ce270fbddad6182870395200b123ad75d412e60cb4328650d1df2cb74ae374e79edf930c030bfb693c9b1891 languageName: node linkType: hard @@ -18978,20 +18973,20 @@ __metadata: languageName: node linkType: hard -"vite@npm:^8.0.3": - version: 8.0.3 - resolution: "vite@npm:8.0.3" +"vite@npm:^8.0.14": + version: 8.0.14 + resolution: "vite@npm:8.0.14" dependencies: fsevents: "npm:~2.3.3" lightningcss: "npm:^1.32.0" picomatch: "npm:^4.0.4" - postcss: "npm:^8.5.8" - rolldown: "npm:1.0.0-rc.12" - tinyglobby: "npm:^0.2.15" + postcss: "npm:^8.5.15" + rolldown: "npm:1.0.2" + tinyglobby: "npm:^0.2.16" peerDependencies: "@types/node": ^20.19.0 || >=22.12.0 - "@vitejs/devtools": ^0.1.0 - esbuild: ^0.27.0 + "@vitejs/devtools": ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 jiti: ">=1.21.0" less: ^4.0.0 sass: ^1.70.0 @@ -19031,7 +19026,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10/745b791cb71297ac3877af061da44751d93f198413426bbb76a1f8384d76d4162a6ad739b2bcdf5fb966cd1295db59412614aee60738e40e1c99cee561e682f0 + checksum: 10/3747c9b9dabdfa5b840630c39b2c764afb3c3762816f3148afe7d516edc1889b60b666adeb4e98761c26fb8ed5ba3a9770df5c0450443daf4cdfac110bc6df1c languageName: node linkType: hard From b17529c4e876911ac7c0d9f17c26edd45d316d63 Mon Sep 17 00:00:00 2001 From: Corey Martin Date: Wed, 27 May 2026 08:13:25 -0700 Subject: [PATCH 40/56] [js] Update react-router-dom dependency (#27861) ## Overview Updates the `react-router-dom` 6.x pins from `6.11.2` to `6.30.3` across the six workspace consumers. This keeps the repo on React Router 6 while moving the bundled router package to `@remix-run/router@1.23.2`.
Related advisories - [CVE-2026-22029](https://www.cve.org/CVERecord?id=CVE-2026-22029) - [GHSA-2w69-qvjg-hvjx](https://github.com/advisories/GHSA-2w69-qvjg-hvjx)
Related advisories - [CVE-2026-22029](https://www.cve.org/CVERecord?id=CVE-2026-22029) / [GHSA-2w69-qvjg-hvjx](https://github.com/advisories/GHSA-2w69-qvjg-hvjx)
## Test plan - `npm view @remix-run/router version` - `npm view react-router-dom@6 version dependencies.@remix-run/router dependencies.react-router --json` - `yarn why @remix-run/router` - `yarn why react-router-dom` - `yarn install --immutable` - `yarn deps:check` - `git diff --check` - `yarn why @remix-run/router | rg '@remix-run/router@npm:1\\.(?:[0-9]|1[0-9]|2[0-2])\\.|@remix-run/router@npm:1\\.23\\.[01]' || true` - `yarn turbo run types --filter=@lightsparkdev/ui --filter=@lightsparkdev/ops --filter=@lightsparkdev/site --filter=@lightsparkdev/uma-bridge --filter=@lightsparkdev/ui-test-app --filter=@lightsparkdev/oauth-app` - pre-commit hook: `yarn install`, `yarn format` GitOrigin-RevId: d5f121e59b1ee7e6a2a69e92764f56d46878056d --- apps/examples/oauth-app/package.json | 2 +- apps/examples/ui-test-app/package.json | 2 +- packages/ui/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/examples/oauth-app/package.json b/apps/examples/oauth-app/package.json index 715591327..e297b77c2 100644 --- a/apps/examples/oauth-app/package.json +++ b/apps/examples/oauth-app/package.json @@ -11,7 +11,7 @@ "@lightsparkdev/ui": "1.1.19", "react": "^18.2.0", "react-dom": "^18.1.0", - "react-router-dom": "6.11.2", + "react-router-dom": "6.30.3", "web-vitals": "^3.3.0" }, "devDependencies": { diff --git a/apps/examples/ui-test-app/package.json b/apps/examples/ui-test-app/package.json index 0f1990739..ee4380f01 100644 --- a/apps/examples/ui-test-app/package.json +++ b/apps/examples/ui-test-app/package.json @@ -33,7 +33,7 @@ "@lightsparkdev/ui": "1.1.19", "react": "^18.2.0", "react-dom": "^18.1.0", - "react-router-dom": "6.11.2" + "react-router-dom": "6.30.3" }, "devDependencies": { "@babel/core": "^7.21.4", diff --git a/packages/ui/package.json b/packages/ui/package.json index fe6fbf360..fda716f03 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -126,7 +126,7 @@ "react-datetime-picker": "^5.6.0", "react-device-detect": "^2.2.3", "react-dom": "^18.1.0", - "react-router-dom": "6.11.2", + "react-router-dom": "6.30.3", "react-select": "^5.4.0", "react-tooltip": "^5.10.1", "uuid": "^9.0.0" From 37493d8dd111c39dd2c80c5402a50c5ae9b820c3 Mon Sep 17 00:00:00 2001 From: Lightspark Eng Date: Wed, 27 May 2026 16:48:11 +0000 Subject: [PATCH 41/56] CI update lock file for PR --- yarn.lock | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/yarn.lock b/yarn.lock index e88e77032..2aec0a70f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2989,7 +2989,7 @@ __metadata: prettier-plugin-organize-imports: "npm:^3.2.4" react: "npm:^18.2.0" react-dom: "npm:^18.1.0" - react-router-dom: "npm:6.11.2" + react-router-dom: "npm:6.30.3" tsc-absolute: "npm:^1.0.1" typescript: "npm:^5.6.2" vite: "npm:^8.0.14" @@ -3144,7 +3144,7 @@ __metadata: prettier-plugin-organize-imports: "npm:^3.2.4" react: "npm:^18.2.0" react-dom: "npm:^18.1.0" - react-router-dom: "npm:6.11.2" + react-router-dom: "npm:6.30.3" resize-observer-polyfill: "npm:^1.5.1" ts-jest: "npm:^29.1.1" tsc-absolute: "npm:^1.0.1" @@ -3208,7 +3208,7 @@ __metadata: react-datetime-picker: "npm:^5.6.0" react-device-detect: "npm:^2.2.3" react-dom: "npm:^18.1.0" - react-router-dom: "npm:6.11.2" + react-router-dom: "npm:6.30.3" react-select: "npm:^5.4.0" react-tooltip: "npm:^5.10.1" ts-jest: "npm:^29.1.1" @@ -4310,10 +4310,10 @@ __metadata: languageName: node linkType: hard -"@remix-run/router@npm:1.6.2": - version: 1.6.2 - resolution: "@remix-run/router@npm:1.6.2" - checksum: 10/c261c3b52f08d7fcacce9c66d68dba3b6f0c8263ea15f69f9f1c89734685cdfe4f383c879324acade68cb331d48e3deca9ec00734abe08d9694e529096907f40 +"@remix-run/router@npm:1.23.2": + version: 1.23.2 + resolution: "@remix-run/router@npm:1.23.2" + checksum: 10/50eb497854881bbd2e1016d4eb83c935ecd618e1c3888b74718851317e3b04edbaae9fe1baa49ec08c5c52cfe7118f4664e37144813d9500f45f922d6602a782 languageName: node linkType: hard @@ -15653,27 +15653,27 @@ __metadata: languageName: node linkType: hard -"react-router-dom@npm:6.11.2": - version: 6.11.2 - resolution: "react-router-dom@npm:6.11.2" +"react-router-dom@npm:6.30.3": + version: 6.30.3 + resolution: "react-router-dom@npm:6.30.3" dependencies: - "@remix-run/router": "npm:1.6.2" - react-router: "npm:6.11.2" + "@remix-run/router": "npm:1.23.2" + react-router: "npm:6.30.3" peerDependencies: react: ">=16.8" react-dom: ">=16.8" - checksum: 10/85575793cbdb84b05e9c33fef6f81e6b09e9f2606d2ba03392f83689dbb240212e5b22634b95049fc19364e9b44d45a519387d1bff4eba8a163548aa3376bc0f + checksum: 10/db974d801070e9967a076b31edca902e127793e02dc79f364461b94e81846a588c241d72e069f5b586b4a90ffd99798f5cb97753ac9d22fe90afa6dc008ab520 languageName: node linkType: hard -"react-router@npm:6.11.2": - version: 6.11.2 - resolution: "react-router@npm:6.11.2" +"react-router@npm:6.30.3": + version: 6.30.3 + resolution: "react-router@npm:6.30.3" dependencies: - "@remix-run/router": "npm:1.6.2" + "@remix-run/router": "npm:1.23.2" peerDependencies: react: ">=16.8" - checksum: 10/a40d1ea78e3b5b3167ed6cbaf74b2e60592fd1822b9f94a2499933bf699130a81f669bc06bdf34f38489a96d31510848c21254a48e49038b18ecbf42993eaa34 + checksum: 10/1a51bdcc42b8d7979228dea8b5c44a28a4add9b681781f75b74f5f920d20058a92ffe5f1d0ba0621f03abe1384b36025b53b402515ecb35f27a6a2f2f25d6fbe languageName: node linkType: hard From 729ded54002d8eed0add69649b2d46b0be417726 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Wed, 27 May 2026 17:01:37 -0700 Subject: [PATCH 42/56] DEMO(grid): add internal demo app for hosted KYC/KYB link API (#27615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-page Vite app under `js/apps/examples/grid-kyc-demo/` that exercises `POST /customers` + `POST /customers/{id}/kyc-link` end-to-end. Internal demo only — not a public tool. ## Why We have the hosted KYC/KYB link API but no quick way to exercise it end-to-end for internal testing or partner walkthroughs. This app fills that gap without needing a backend. ## What you get - **Credentials at startup**, persisted in `sessionStorage` only — keys never touch a server. - **Environment switcher** (prod / dev), per-env credential storage so prod and dev keys don't get mixed up. - **Customer-type toggle** drives either the INDIVIDUAL (KYC) or BUSINESS (KYB) create payload, then generates the hosted link from `/kyc-link`. - **Result panel** surfaces the `kycUrl` with Open / Copy buttons and shows the provider token (informational — embedded SDK flow is a follow-up). - Optional `GET /customers/{id}` button to poll `kycStatus` / `kybStatus`. - All calls go through Vite's `/api/` proxy → `api.lightspark.com/grid/2025-10-13` or `api.dev.dev.sparkinfra.net/grid/rc`. ## Test plan - `cd js/apps/examples/grid-kyc-demo && yarn dev` → loads on `http://localhost:3107`. - Toggle INDIVIDUAL ↔ BUSINESS → field set switches. - "Test Auth" with bogus creds → real `HTTP 401` from each env (proxy wiring confirmed end-to-end against both prod and dev). - Full flow against dev: create customer → generate KYC link → open hosted URL → complete the flow → fetch customer status returns `PENDING`/`APPROVED`. GitOrigin-RevId: 74d7ce34b32361976fb1ea4e39ccd226872b85a7 --- apps/examples/grid-kyc-demo/README.md | 47 + apps/examples/grid-kyc-demo/index.html | 12 + apps/examples/grid-kyc-demo/package.json | 25 + apps/examples/grid-kyc-demo/public/fonts | 1 + apps/examples/grid-kyc-demo/src/App.tsx | 1188 +++++++++++++++++ apps/examples/grid-kyc-demo/src/api.ts | 110 ++ .../grid-kyc-demo/src/declarations.d.ts | 15 + apps/examples/grid-kyc-demo/src/main.tsx | 14 + apps/examples/grid-kyc-demo/tsconfig.json | 16 + apps/examples/grid-kyc-demo/vite.config.ts | 46 + apps/examples/settings.json | 3 + 11 files changed, 1477 insertions(+) create mode 100644 apps/examples/grid-kyc-demo/README.md create mode 100644 apps/examples/grid-kyc-demo/index.html create mode 100644 apps/examples/grid-kyc-demo/package.json create mode 120000 apps/examples/grid-kyc-demo/public/fonts create mode 100644 apps/examples/grid-kyc-demo/src/App.tsx create mode 100644 apps/examples/grid-kyc-demo/src/api.ts create mode 100644 apps/examples/grid-kyc-demo/src/declarations.d.ts create mode 100644 apps/examples/grid-kyc-demo/src/main.tsx create mode 100644 apps/examples/grid-kyc-demo/tsconfig.json create mode 100644 apps/examples/grid-kyc-demo/vite.config.ts diff --git a/apps/examples/grid-kyc-demo/README.md b/apps/examples/grid-kyc-demo/README.md new file mode 100644 index 000000000..890c3c763 --- /dev/null +++ b/apps/examples/grid-kyc-demo/README.md @@ -0,0 +1,47 @@ +# grid-kyc-demo + +Internal demo tool for exercising the Grid hosted KYC/KYB link API end-to-end. +Single-page Vite + React app, no backend. Credentials are entered at the top +and live only in this tab's `sessionStorage`. + +## What it does + +- **Create a customer** via `POST /customers` (INDIVIDUAL or BUSINESS). +- **Generate a hosted KYC link** via `POST /customers/{id}/kyc-link` and open it + in a new tab. +- **Poll customer status** via `GET /customers/{id}` so you can watch + `kycStatus` / `kybStatus` flip after the hosted flow completes. + +Every request and response is appended to a rolling log at the bottom of the +page so you can see exactly what's going over the wire. + +## Run it locally + +```bash +cd js/apps/examples/grid-kyc-demo +yarn dev +``` + +Opens on . + +The Vite dev server proxies API calls to one of three environments — pick from +the **Environment** dropdown in the UI: + +| Env | Target | +| ----- | --------------------------------------------------------- | +| prod | `https://api.lightspark.com/grid/2025-10-13` | +| dev | `https://api.dev.dev.sparkinfra.net/grid/rc` | +| local | `http://localhost:5000/grid/rc` (sparkcore on port 5000) | + +Credentials are stored under `grid-kyc-demo:creds:` so prod and dev keys +don't get mixed up. Switching env swaps the visible credential pair. + +## Tips + +- The platform you're calling against needs `customer_kyc_mode = GRID_SWITCH_OWNED` + on at least one of its currencies, otherwise grid auto-approves new customers + on creation and the link flow has nothing to do. +- For INDIVIDUAL customers on the LSP grid switch, the + `LSP_INDIVIDUAL_KYC_ENABLED` gatekeeper also has to be on for the platform. +- The redirect URI must be `https://` — Sumsub rejects `http://` and localhost. + Leave the field blank to use Sumsub's default post-flow page. diff --git a/apps/examples/grid-kyc-demo/index.html b/apps/examples/grid-kyc-demo/index.html new file mode 100644 index 000000000..ab1c471e8 --- /dev/null +++ b/apps/examples/grid-kyc-demo/index.html @@ -0,0 +1,12 @@ + + + + + + Grid KYC/KYB Demo + + +
+ + + diff --git a/apps/examples/grid-kyc-demo/package.json b/apps/examples/grid-kyc-demo/package.json new file mode 100644 index 000000000..b63b274a3 --- /dev/null +++ b/apps/examples/grid-kyc-demo/package.json @@ -0,0 +1,25 @@ +{ + "name": "@lightsparkdev/grid-kyc-demo", + "private": true, + "version": "0.0.1", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "start": "vite", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.11.0", + "@emotion/styled": "^11.11.0", + "@lightsparkdev/origin": "*", + "react": "^18.2.0", + "react-dom": "^18.1.0" + }, + "devDependencies": { + "@types/react": "^18.2.12", + "@types/react-dom": "^18.0.0", + "@vitejs/plugin-react": "^5.2.0", + "typescript": "^5.6.2", + "vite": "^8.0.3" + } +} diff --git a/apps/examples/grid-kyc-demo/public/fonts b/apps/examples/grid-kyc-demo/public/fonts new file mode 120000 index 000000000..7bf131b0d --- /dev/null +++ b/apps/examples/grid-kyc-demo/public/fonts @@ -0,0 +1 @@ +../../../../packages/origin/public/fonts \ No newline at end of file diff --git a/apps/examples/grid-kyc-demo/src/App.tsx b/apps/examples/grid-kyc-demo/src/App.tsx new file mode 100644 index 000000000..79eb60519 --- /dev/null +++ b/apps/examples/grid-kyc-demo/src/App.tsx @@ -0,0 +1,1188 @@ +import styled from "@emotion/styled"; +import { + Alert, + Badge, + Button, + Card, + Field, + Input, + Select, + Textarea, +} from "@lightsparkdev/origin"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { + callGrid, + ENV_LABELS, + nowTs, + randomSuffix, + type CustomerCreateResponse, + type GridCredentials, + type GridEnv, + type KycLinkResponse, + type LogEntry, +} from "./api"; + +type CustomerType = "INDIVIDUAL" | "BUSINESS"; +type Status = { kind: "ok" | "err"; message: string } | null; + +const ENV_STORAGE_KEY = "grid-kyc-demo:env"; +const CREDS_STORAGE_KEY_PREFIX = "grid-kyc-demo:creds:"; + +const ENTITY_TYPES = [ + "SOLE_PROPRIETORSHIP", + "PARTNERSHIP", + "LLC", + "CORPORATION", + "S_CORPORATION", + "NON_PROFIT", + "OTHER", +] as const; + +const BUSINESS_TYPES = [ + "AGRICULTURE_FORESTRY_FISHING_AND_HUNTING", + "MINING_QUARRYING_AND_OIL_AND_GAS_EXTRACTION", + "UTILITIES", + "CONSTRUCTION", + "MANUFACTURING", + "WHOLESALE_TRADE", + "RETAIL_TRADE", + "TRANSPORTATION_AND_WAREHOUSING", + "INFORMATION", + "FINANCE_AND_INSURANCE", + "REAL_ESTATE_AND_RENTAL_AND_LEASING", + "PROFESSIONAL_SCIENTIFIC_AND_TECHNICAL_SERVICES", + "MANAGEMENT_OF_COMPANIES_AND_ENTERPRISES", + "ADMINISTRATIVE_AND_SUPPORT_AND_WASTE_MANAGEMENT_AND_REMEDIATION_SERVICES", + "EDUCATIONAL_SERVICES", + "HEALTH_CARE_AND_SOCIAL_ASSISTANCE", + "ARTS_ENTERTAINMENT_AND_RECREATION", + "ACCOMMODATION_AND_FOOD_SERVICES", + "OTHER_SERVICES", + "PUBLIC_ADMINISTRATION", +] as const; + +const PURPOSE_OF_ACCOUNT = [ + "CONTRACTOR_PAYOUTS", + "CREATOR_PAYOUTS", + "EMPLOYEE_PAYOUTS", + "MARKETPLACE_SELLER_PAYOUTS", + "SUPPLIER_PAYMENTS", + "CROSS_BORDER_B2B", + "AR_AUTOMATION", + "AP_AUTOMATION", + "EMBEDDED_PAYMENTS", + "PLATFORM_FEE_COLLECTION", + "P2P_TRANSFERS", + "CHARITABLE_DONATIONS", + "OTHER", +] as const; + +const TX_COUNT = [ + "COUNT_UNDER_10", + "COUNT_10_TO_100", + "COUNT_100_TO_500", + "COUNT_500_TO_1000", + "COUNT_OVER_1000", +] as const; + +const TX_VOLUME = [ + "VOLUME_UNDER_10K", + "VOLUME_10K_TO_100K", + "VOLUME_100K_TO_1M", + "VOLUME_1M_TO_10M", + "VOLUME_OVER_10M", +] as const; + +interface IndividualForm { + platformCustomerId: string; + region: string; + fullName: string; + birthDate: string; + nationality: string; + email: string; + currencies: string; +} + +interface BusinessForm { + platformCustomerId: string; + region: string; + currencies: string; + legalName: string; + doingBusinessAs: string; + country: string; + registrationNumber: string; + incorporatedOn: string; + entityType: string; + taxId: string; + countriesOfOperation: string; + businessType: string; + purposeOfAccount: string; + sourceOfFunds: string; + txCount: string; + txVolume: string; + recipientJurisdictions: string; + addrLine1: string; + addrLine2: string; + addrCity: string; + addrState: string; + addrPostal: string; + addrCountry: string; +} + +function defaultIndividual(): IndividualForm { + return { + platformCustomerId: `ind-${randomSuffix()}`, + region: "US", + fullName: "Jane Smith", + birthDate: "1990-01-15", + nationality: "US", + email: "", + currencies: "USD,USDC", + }; +} + +function defaultBusiness(): BusinessForm { + return { + platformCustomerId: `biz-${randomSuffix()}`, + region: "US", + currencies: "USD,USDC", + legalName: "Acme Corporation", + doingBusinessAs: "Acme", + country: "US", + registrationNumber: "5523041", + incorporatedOn: "2018-03-14", + entityType: "LLC", + taxId: "47-1234567", + countriesOfOperation: "US", + businessType: "INFORMATION", + purposeOfAccount: "CONTRACTOR_PAYOUTS", + sourceOfFunds: "Funds derived from customer payments for software services", + txCount: "COUNT_100_TO_500", + txVolume: "VOLUME_100K_TO_1M", + recipientJurisdictions: "US,MX", + addrLine1: "123 Market Street", + addrLine2: "Suite 400", + addrCity: "San Francisco", + addrState: "CA", + addrPostal: "94105", + addrCountry: "US", + }; +} + +function splitCsv(value: string): string[] { + return value + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +function buildIndividualPayload(form: IndividualForm): Record { + const currencies = splitCsv(form.currencies); + const payload: Record = { + customerType: "INDIVIDUAL", + platformCustomerId: form.platformCustomerId.trim(), + region: form.region.trim(), + fullName: form.fullName.trim(), + birthDate: form.birthDate, + nationality: form.nationality.trim(), + }; + if (currencies.length) payload.currencies = currencies; + if (form.email.trim()) payload.email = form.email.trim(); + return payload; +} + +function buildBusinessPayload(form: BusinessForm): Record { + const currencies = splitCsv(form.currencies); + const businessInfo: Record = { + legalName: form.legalName.trim(), + country: form.country.trim(), + registrationNumber: form.registrationNumber.trim(), + incorporatedOn: form.incorporatedOn, + entityType: form.entityType, + taxId: form.taxId.trim(), + countriesOfOperation: splitCsv(form.countriesOfOperation), + businessType: form.businessType, + purposeOfAccount: form.purposeOfAccount, + sourceOfFunds: form.sourceOfFunds.trim(), + expectedMonthlyTransactionCount: form.txCount, + expectedMonthlyTransactionVolume: form.txVolume, + expectedRecipientJurisdictions: splitCsv(form.recipientJurisdictions), + }; + if (form.doingBusinessAs.trim()) + businessInfo.doingBusinessAs = form.doingBusinessAs.trim(); + + const address: Record = { + line1: form.addrLine1.trim(), + city: form.addrCity.trim(), + state: form.addrState.trim(), + postalCode: form.addrPostal.trim(), + country: form.addrCountry.trim(), + }; + if (form.addrLine2.trim()) address.line2 = form.addrLine2.trim(); + + const payload: Record = { + customerType: "BUSINESS", + platformCustomerId: form.platformCustomerId.trim(), + region: form.region.trim(), + businessInfo, + address, + }; + if (currencies.length) payload.currencies = currencies; + return payload; +} + +export function App() { + const [env, setEnv] = useState(envInitial); + const [creds, setCreds] = useState(() => + loadCreds(envInitial()), + ); + const [customerType, setCustomerType] = useState("INDIVIDUAL"); + const [individual, setIndividual] = useState( + defaultIndividual, + ); + const [business, setBusiness] = useState(defaultBusiness); + const [customerId, setCustomerId] = useState(""); + const [redirectUri, setRedirectUri] = useState(""); + const [kycLink, setKycLink] = useState(null); + + const [pingStatus, setPingStatus] = useState(null); + const [createStatus, setCreateStatus] = useState(null); + const [linkStatus, setLinkStatus] = useState(null); + const [fetchStatus, setFetchStatus] = useState(null); + + const [log, setLog] = useState([]); + const logIdRef = useRef(0); + + // Persist env across reloads; swap creds when env changes. + useEffect(() => { + sessionStorage.setItem(ENV_STORAGE_KEY, env); + setCreds(loadCreds(env)); + }, [env]); + + // Persist creds synchronously when the user edits them. We can't run this + // through a `[creds, env]` effect: that fires once with (oldCreds, newEnv) + // mid-transition during an env switch, briefly writing the previous + // env's credentials into the new env's storage slot before the next + // render corrects it. Driving the write from the input handlers and + // `onClearCreds` keeps persistence in lockstep with the action that + // caused it, and the env-swap effect above owns its own loadCreds + // round-trip. + const persistCreds = useCallback( + (next: GridCredentials) => { + const id = next.id.trim(); + const secret = next.secret.trim(); + const key = CREDS_STORAGE_KEY_PREFIX + env; + if (!id && !secret) sessionStorage.removeItem(key); + else sessionStorage.setItem(key, JSON.stringify({ id, secret })); + }, + [env], + ); + + const appendLog = useCallback((entry: Omit) => { + const id = ++logIdRef.current; + setLog((prev) => [{ id, ts: nowTs(), ...entry }, ...prev].slice(0, 100)); + }, []); + + const runCall = useCallback( + async ( + method: "GET" | "POST", + path: string, + body?: unknown, + ): Promise => { + try { + const result = await callGrid({ env, creds, method, path, body }); + appendLog({ + env, + method, + path, + requestBody: body, + status: result.status, + responseBody: result.data, + }); + return result.data; + } catch (err) { + const e = err as Error & { status?: number; body?: unknown }; + appendLog({ + env, + method, + path, + requestBody: body, + status: e.status, + responseBody: e.body, + error: e.message, + }); + throw err; + } + }, + [env, creds, appendLog], + ); + + const onPing = useCallback(async () => { + try { + const data = await runCall<{ data?: unknown[] }>( + "GET", + "/customers?limit=1", + ); + const count = Array.isArray(data?.data) ? data.data.length : 0; + setPingStatus({ kind: "ok", message: `OK — listed ${count} customer(s).` }); + } catch (err) { + setPingStatus({ kind: "err", message: (err as Error).message }); + } + }, [runCall]); + + const onClearCreds = useCallback(() => { + const empty = { id: "", secret: "" }; + setCreds(empty); + persistCreds(empty); + }, [persistCreds]); + + const onCreateCustomer = useCallback(async () => { + try { + const payload = + customerType === "INDIVIDUAL" + ? buildIndividualPayload(individual) + : buildBusinessPayload(business); + const data = await runCall( + "POST", + "/customers", + payload, + ); + if (data) { + setCustomerId(data.id); + setCreateStatus({ + kind: "ok", + message: `Created ${data.customerType} customer ${data.id}`, + }); + } + } catch (err) { + setCreateStatus({ kind: "err", message: (err as Error).message }); + } + }, [customerType, individual, business, runCall]); + + const onGenerateLink = useCallback(async () => { + setKycLink(null); + try { + const id = customerId.trim(); + if (!id) throw new Error("Customer ID required."); + const body = redirectUri.trim() ? { redirectUri: redirectUri.trim() } : undefined; + const data = await runCall( + "POST", + `/customers/${encodeURIComponent(id)}/kyc-link`, + body, + ); + if (data) { + setKycLink(data); + setLinkStatus({ + kind: "ok", + message: `Link generated — expires ${data.expiresAt}`, + }); + } + } catch (err) { + setLinkStatus({ kind: "err", message: (err as Error).message }); + } + }, [customerId, redirectUri, runCall]); + + const onFetchCustomer = useCallback(async () => { + try { + const id = customerId.trim(); + if (!id) throw new Error("Customer ID required."); + const data = await runCall( + "GET", + `/customers/${encodeURIComponent(id)}`, + ); + if (data) { + const status = data.kycStatus ?? data.kybStatus ?? "(unknown)"; + setFetchStatus({ + kind: "ok", + message: `${data.customerType} status: ${status}`, + }); + } + } catch (err) { + setFetchStatus({ kind: "err", message: (err as Error).message }); + } + }, [customerId, runCall]); + + const customerTypeOptions = useMemo( + () => [ + { value: "INDIVIDUAL", label: "INDIVIDUAL — KYC hosted link" }, + { value: "BUSINESS", label: "BUSINESS — KYB hosted link" }, + ], + [], + ); + + return ( + + + + Grid KYC/KYB Demo + + Internal demo tool for exercising the Grid hosted KYC/KYB link + API. Everything runs client-side — credentials live in this + browser tab only. Requests are proxied through Vite to the + selected environment. + + + + + + + Environment & credentials + + Credentials are stored per environment in sessionStorage so + prod and dev keys don't get mixed up. + + + + + + + Environment + setEnv(v as GridEnv)} + items={[ + { value: "prod", label: ENV_LABELS.prod }, + { value: "dev", label: ENV_LABELS.dev }, + { value: "local", label: ENV_LABELS.local }, + ]} + /> + + + + API Client ID + { + const next = { ...creds, id: e.target.value }; + setCreds(next); + persistCreds(next); + }} + autoComplete="off" + /> + + + API Client Secret + { + const next = { ...creds, secret: e.target.value }; + setCreds(next); + persistCreds(next); + }} + autoComplete="off" + /> + + + + + + + {pingStatus && ( + + )} + + + + + + + + Customer + + The customer type determines the create payload and whether the + link is KYC (individual) or KYB (business). Either way the link + is generated by POST /customers/<id>/kyc-link. + + + + + + + Customer type + setCustomerType(v as CustomerType)} + items={customerTypeOptions} + /> + + + {customerType === "INDIVIDUAL" ? ( + + ) : ( + + )} + + + + + + + + Run the flow + + + + + + {createStatus && ( + + )} + + + + + Customer ID + setCustomerId(e.target.value)} + placeholder="auto-filled from Create Customer" + /> + + + Redirect URI (optional) + setRedirectUri(e.target.value)} + placeholder="https://app.example.com/onboarding/done" + /> + + Where Sumsub sends the customer after the hosted flow. Must be + https://; Sumsub rejects http:// and + localhost URLs. Leave blank to use Sumsub's default + post-flow page. + + + + {linkStatus && ( + + )} + {kycLink && } + + + + + {fetchStatus && ( + + )} + + + + + + + + Response log + + Most recent first. Cleared on reload. + + + + + {log.length === 0 ? ( + No requests yet. + ) : ( + + {log.map((entry) => ( + + ))} + + )} + + + + + ); +} + +function IndividualFields({ + form, + onChange, +}: { + form: IndividualForm; + onChange: (next: IndividualForm) => void; +}) { + const set = ( + key: K, + value: IndividualForm[K], + ) => onChange({ ...form, [key]: value }); + return ( + <> + + + Platform customer ID + set("platformCustomerId", e.target.value)} + /> + + + Region (ISO 3166-1) + set("region", e.target.value)} + /> + + + + + Full name + set("fullName", e.target.value)} + /> + + + Birth date + set("birthDate", e.target.value)} + /> + + + + + Nationality (ISO 3166-1) + set("nationality", e.target.value)} + /> + + + Email (optional) + set("email", e.target.value)} + /> + + + + Currencies (comma-separated, optional) + set("currencies", e.target.value)} + /> + + + ); +} + +function BusinessFields({ + form, + onChange, +}: { + form: BusinessForm; + onChange: (next: BusinessForm) => void; +}) { + const set = (key: K, value: BusinessForm[K]) => + onChange({ ...form, [key]: value }); + return ( + <> + + + Platform customer ID + set("platformCustomerId", e.target.value)} + /> + + + Region (ISO 3166-1) + set("region", e.target.value)} + /> + + + + Currencies (comma-separated, optional) + set("currencies", e.target.value)} + /> + + + Business info + + + Legal name + set("legalName", e.target.value)} + /> + + + Doing business as (optional) + set("doingBusinessAs", e.target.value)} + /> + + + + + Country of incorporation + set("country", e.target.value)} + /> + + + Registration number + set("registrationNumber", e.target.value)} + /> + + + + + Incorporated on + set("incorporatedOn", e.target.value)} + /> + + + Entity type + set("entityType", v)} + items={ENTITY_TYPES.map((v) => ({ value: v, label: v }))} + /> + + + + + Tax ID + set("taxId", e.target.value)} + /> + + + Countries of operation + set("countriesOfOperation", e.target.value)} + /> + + + + + Business type + set("businessType", v)} + items={BUSINESS_TYPES.map((v) => ({ value: v, label: v }))} + /> + + + Purpose of account + set("purposeOfAccount", v)} + items={PURPOSE_OF_ACCOUNT.map((v) => ({ value: v, label: v }))} + /> + + + + Source of funds + set("sourceOfFunds", e.target.value)} + /> + + + + Expected monthly tx count + set("txCount", v)} + items={TX_COUNT.map((v) => ({ value: v, label: v }))} + /> + + + Expected monthly tx volume + set("txVolume", v)} + items={TX_VOLUME.map((v) => ({ value: v, label: v }))} + /> + + + + Recipient jurisdictions + set("recipientJurisdictions", e.target.value)} + /> + + + Business address + + + Line 1 + set("addrLine1", e.target.value)} + /> + + + Line 2 (optional) + set("addrLine2", e.target.value)} + /> + + + + + City + set("addrCity", e.target.value)} + /> + + + State + set("addrState", e.target.value)} + /> + + + + + Postal code + set("addrPostal", e.target.value)} + /> + + + Country (ISO 3166-1) + set("addrCountry", e.target.value)} + /> + + + + ); +} + +function SelectControl({ + value, + onValueChange, + items, +}: { + value: string; + onValueChange: (next: string) => void; + items: { value: string; label: string }[]; +}) { + return ( + { + if (next != null) onValueChange(next); + }} + > + + + {(v: string) => items.find((i) => i.value === v)?.label ?? v} + + + + + + + + {items.map((item) => ( + + + {item.label} + + ))} + + + + + + ); +} + +function KycLinkResult({ result }: { result: KycLinkResponse }) { + const [copied, setCopied] = useState(false); + return ( + + + {result.provider} + expires {result.expiresAt} + + {result.kycUrl} + + + + + {result.token && ( + + Provider token (for embedded SDK, follow-up):{" "} + {result.token.slice(0, 32)}… + + )} + + ); +} + +function LogItem({ entry }: { entry: LogEntry }) { + const headline = `${entry.method} ${entry.path}`; + const statusBadgeVariant: "green" | "red" | "gray" = entry.error + ? "red" + : entry.status && entry.status >= 200 && entry.status < 300 + ? "green" + : "gray"; + return ( + + + {entry.env} + + {entry.status ?? "ERR"} + + {headline} + {entry.ts} + + {entry.requestBody !== undefined && ( +

PASSKEY lifecycle

-

Create wallet

+

Create credential

, + extraHeaders: Record = {}, +): Promise<{ status: number; data: unknown }> { + const res = await fetch(API_BASE + path, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: getAuthHeader(), + ...extraHeaders, + }, + body: JSON.stringify(body), + }); + const raw = await res.text(); + const data = raw ? JSON.parse(raw) : null; + if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`); + return { status: res.status, data }; +} + async function apiGet(path: string): Promise { const res = await fetch(API_BASE + path, { headers: { Authorization: getAuthHeader() }, @@ -322,7 +342,11 @@ bindClick( platformCustomerId, region: "US", currencies: ["USDB"], - businessInfo: { legalName: fullName }, + businessInfo: { + legalName: fullName, + taxId: "12-3456789", + incorporatedOn: "2020-01-01", + }, }; if (email) body.email = email; const { data: customer } = await apiPost("/customers", body); @@ -335,12 +359,78 @@ bindClick( addLog("Internal Accounts", accounts); if (accounts.data && accounts.data.length > 0) { setCtxAccount(accounts.data[0].id); - return `Customer: ${customerId}\nAccount: ${accounts.data[0].id}`; + return `Customer: ${customerId}\nAccount: ${accounts.data[0].id}\nEmbedded wallet pre-created at customer-create time.`; } - return `Customer: ${customerId}\nNo USDB account found`; + return `Customer: ${customerId}\nNo USDB account found yet — wallet provisioning may be in progress.`; }, ); +// ========================================================== +// Platform config (OTP + branding) — GET to populate, PATCH to save +// ========================================================== + +const cfgAppName = maybeEl("cfg-app-name"); +const cfgOtpLength = maybeEl("cfg-otp-length"); +const cfgAlphanumeric = maybeEl("cfg-alphanumeric"); +const cfgExpirationSeconds = maybeEl("cfg-expiration-seconds"); +const cfgSendFromEmail = maybeEl("cfg-send-from-email"); +const cfgSendFromName = maybeEl("cfg-send-from-name"); +const cfgReplyToEmail = maybeEl("cfg-reply-to-email"); +const cfgLogoUrl = maybeEl("cfg-logo-url"); + +function readConfigForm(): Record { + // Only include fields the user touched (non-empty) so we PATCH a real partial. + const ewc: Record = {}; + if (cfgAppName?.value.trim()) ewc.appName = cfgAppName.value.trim(); + if (cfgOtpLength?.value.trim()) + ewc.otpLength = parseInt(cfgOtpLength.value, 10); + if (cfgAlphanumeric) ewc.alphanumeric = cfgAlphanumeric.checked; + if (cfgExpirationSeconds?.value.trim()) + ewc.expirationSeconds = parseInt(cfgExpirationSeconds.value, 10); + if (cfgSendFromEmail?.value.trim()) + ewc.sendFromEmailAddress = cfgSendFromEmail.value.trim(); + if (cfgSendFromName?.value.trim()) + ewc.sendFromEmailSenderName = cfgSendFromName.value.trim(); + if (cfgReplyToEmail?.value.trim()) + ewc.replyToEmailAddress = cfgReplyToEmail.value.trim(); + if (cfgLogoUrl?.value.trim()) ewc.logoUrl = cfgLogoUrl.value.trim(); + return { embeddedWalletConfig: ewc }; +} + +function applyConfigToForm(cfg: unknown): void { + const ewc = (cfg as { embeddedWalletConfig?: Record }) + ?.embeddedWalletConfig; + if (!ewc) return; + if (cfgAppName && typeof ewc.appName === "string") cfgAppName.value = ewc.appName; + if (cfgOtpLength && typeof ewc.otpLength === "number") + cfgOtpLength.value = String(ewc.otpLength); + if (cfgAlphanumeric && typeof ewc.alphanumeric === "boolean") + cfgAlphanumeric.checked = ewc.alphanumeric; + if (cfgExpirationSeconds && typeof ewc.expirationSeconds === "number") + cfgExpirationSeconds.value = String(ewc.expirationSeconds); + if (cfgSendFromEmail && typeof ewc.sendFromEmailAddress === "string") + cfgSendFromEmail.value = ewc.sendFromEmailAddress; + if (cfgSendFromName && typeof ewc.sendFromEmailSenderName === "string") + cfgSendFromName.value = ewc.sendFromEmailSenderName; + if (cfgReplyToEmail && typeof ewc.replyToEmailAddress === "string") + cfgReplyToEmail.value = ewc.replyToEmailAddress; + if (cfgLogoUrl && typeof ewc.logoUrl === "string") cfgLogoUrl.value = ewc.logoUrl; +} + +bindClick("btn-cfg-load", "cfg-status", "Load Config", "Loading…", async () => { + const cfg = await apiGet("/config"); + addLog("GET /config", cfg); + applyConfigToForm(cfg); + return "Config loaded into form."; +}); + +bindClick("btn-cfg-save", "cfg-status", "Save Config", "Saving…", async () => { + const body = readConfigForm(); + const { data } = await apiPatch("/config", body); + addLog("PATCH /config", data); + return "Config saved."; +}); + bindClick( "btn-fetch-balance", "balance-status", From e784c0a4fa0b035091a6e4edcf9064498f4327cb Mon Sep 17 00:00:00 2001 From: Corey Martin Date: Mon, 1 Jun 2026 13:18:20 -0700 Subject: [PATCH 54/56] [js] Update js-cookie dependency (#27866) ## Reason Refreshes the `js-cookie` version used through the Segment analytics package. ## Overview `@segment/analytics-next@1.84.0` still pins `js-cookie` exactly to `3.0.1`, and `1.84.0` is the current latest Segment package version. This adds a narrow root resolution so the Segment path resolves `js-cookie@3.0.7`.
Related advisories - [CVE-2026-46625](https://www.cve.org/CVERecord?id=CVE-2026-46625) - [GHSA-qjx8-664m-686j](https://github.com/advisories/GHSA-qjx8-664m-686j)
Related advisories - [CVE-2026-46625](https://www.cve.org/CVERecord?id=CVE-2026-46625) / [GHSA-qjx8-664m-686j](https://github.com/advisories/GHSA-qjx8-664m-686j)
## Test plan - `npm view js-cookie version dependencies --json` - `npm view @segment/analytics-next version dependencies peerDependencies --json` - `npm view @segment/analytics-next@latest dependencies.js-cookie --json` - `yarn why js-cookie` - `yarn install --immutable` - `yarn deps:check` - `git diff --check` - `yarn why js-cookie | rg 'js-cookie@npm:3\\.0\\.[0-6]' || true`\n- `yarn turbo run build --filter=@lightsparkdev/site... --filter=@lightsparkdev/uma-bridge...`\n- pre-commit hook: `yarn install`, `yarn format` GitOrigin-RevId: 0477813a11d17bacddbb49e0f9d523096ac11c3b --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 6990a3c25..f86bd557c 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,8 @@ } }, "resolutions": { - "form-data": "4.0.5" + "form-data": "4.0.5", + "js-cookie": "3.0.7" }, "engines": { "node": ">=18.18.0" From fc30cb0945f22320728efb0b541f0e9efa6d057e Mon Sep 17 00:00:00 2001 From: Corey Martin Date: Mon, 1 Jun 2026 15:42:40 -0700 Subject: [PATCH 55/56] [js] Update Vitest to v4 (#28092) ## Reason Socket Security failed on `main` for GitHub Actions run 26779563993 because `vitest@3.2.4` is flagged for a critical CVE in the Site and Origin package manifests. The patched `3.2.x` versions were published today and are blocked by the repo's three-day Yarn minimum-age gate, so this updates to the already-aged v4 line instead of bypassing the gate. ## Overview Updates `vitest` to `^4.1.7` in `@lightsparkdev/site` and `@lightsparkdev/origin`, refreshes `js/yarn.lock`, and adjusts the Chart unit test ResizeObserver mock to be constructable under Vitest v4. ## Test Plan - `yarn install --immutable` - `yarn workspace @lightsparkdev/origin test:unit` - `yarn workspace @lightsparkdev/site test` - `yarn workspace @lightsparkdev/origin types` - `yarn workspace @lightsparkdev/origin lint` (passes with two existing unrelated a11y warnings) - `git diff --check` GitOrigin-RevId: d6427d9bb3cff6dd4ba3d1f27f26cd3d2b5c71a6 --- packages/origin/package.json | 2 +- .../src/components/Chart/Chart.unit.test.ts | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/origin/package.json b/packages/origin/package.json index bcf7cc562..7b67d4a33 100644 --- a/packages/origin/package.json +++ b/packages/origin/package.json @@ -110,7 +110,7 @@ "stylelint-config-standard-scss": "^17.0.0", "typescript": "^5.6.2", "vite": "^8.0.14", - "vitest": "^3.1.4" + "vitest": "^4.1.7" }, "engines": { "node": ">=20.19" diff --git a/packages/origin/src/components/Chart/Chart.unit.test.ts b/packages/origin/src/components/Chart/Chart.unit.test.ts index 24d768687..cbb363665 100644 --- a/packages/origin/src/components/Chart/Chart.unit.test.ts +++ b/packages/origin/src/components/Chart/Chart.unit.test.ts @@ -910,13 +910,16 @@ describe("useResizeWidth", () => { disconnect: vi.fn(), unobserve: vi.fn(), }; - vi.stubGlobal( - "ResizeObserver", - vi.fn((cb: ResizeObserverCallback) => { + class MockResizeObserver { + observe = mockObserver.observe; + disconnect = mockObserver.disconnect; + unobserve = mockObserver.unobserve; + + constructor(cb: ResizeObserverCallback) { observerCallback = cb; - return mockObserver; - }), - ); + } + } + vi.stubGlobal("ResizeObserver", MockResizeObserver); const { result } = renderHook(() => useResizeWidth(800)); expect(result.current.width).toBe(800); From 2c7d6ae3121a88bcbc7dcba2f1b69b13dcc29e02 Mon Sep 17 00:00:00 2001 From: Lightspark Eng Date: Mon, 1 Jun 2026 22:51:02 +0000 Subject: [PATCH 56/56] CI update lock file for PR --- yarn.lock | 352 +++++++++++++++++++++++++----------------------------- 1 file changed, 163 insertions(+), 189 deletions(-) diff --git a/yarn.lock b/yarn.lock index 64d87782c..ec3a750e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3396,7 +3396,7 @@ __metadata: stylelint-config-standard-scss: "npm:^17.0.0" typescript: "npm:^5.6.2" vite: "npm:^8.0.14" - vitest: "npm:^3.1.4" + vitest: "npm:^4.1.7" peerDependencies: next: ">=13" react: ">=18" @@ -5241,6 +5241,13 @@ __metadata: languageName: node linkType: hard +"@standard-schema/spec@npm:^1.1.0": + version: 1.1.0 + resolution: "@standard-schema/spec@npm:1.1.0" + checksum: 10/a209615c9e8b2ea535d7db0a5f6aa0f962fd4ab73ee86a46c100fb78116964af1f55a27c1794d4801e534a196794223daa25ff5135021e03c7828aa3d95e1763 + languageName: node + linkType: hard + "@storybook/builder-vite@npm:10.3.6": version: 10.3.6 resolution: "@storybook/builder-vite@npm:10.3.6" @@ -6670,26 +6677,40 @@ __metadata: languageName: node linkType: hard -"@vitest/mocker@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/mocker@npm:3.2.4" +"@vitest/expect@npm:4.1.7": + version: 4.1.7 + resolution: "@vitest/expect@npm:4.1.7" dependencies: - "@vitest/spy": "npm:3.2.4" + "@standard-schema/spec": "npm:^1.1.0" + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:4.1.7" + "@vitest/utils": "npm:4.1.7" + chai: "npm:^6.2.2" + tinyrainbow: "npm:^3.1.0" + checksum: 10/a609af6c0497cd510ce8aed099f18faf6d6642bc8eb3432b688f2b39d7354a04d1c4ee9dc28bcfb9d4be701ceac88384d586592a520a324b3773ea43e8a1e677 + languageName: node + linkType: hard + +"@vitest/mocker@npm:4.1.7": + version: 4.1.7 + resolution: "@vitest/mocker@npm:4.1.7" + dependencies: + "@vitest/spy": "npm:4.1.7" estree-walker: "npm:^3.0.3" - magic-string: "npm:^0.30.17" + magic-string: "npm:^0.30.21" peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - checksum: 10/5e92431b6ed9fc1679060e4caef3e4623f4750542a5d7cd944774f8217c4d231e273202e8aea00bab33260a5a9222ecb7005d80da0348c3c829bd37d123071a8 + checksum: 10/124d0ec9cc099fde1fca4b065b81a389e9ba2204ecba9729751a0a022d0ffaa34609d9dc60c1f8494ee972c2209035a4476ff1dddc1790e07d1ca28a1103b30d languageName: node linkType: hard -"@vitest/pretty-format@npm:3.2.4, @vitest/pretty-format@npm:^3.2.4": +"@vitest/pretty-format@npm:3.2.4": version: 3.2.4 resolution: "@vitest/pretty-format@npm:3.2.4" dependencies: @@ -6698,25 +6719,34 @@ __metadata: languageName: node linkType: hard -"@vitest/runner@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/runner@npm:3.2.4" +"@vitest/pretty-format@npm:4.1.7": + version: 4.1.7 + resolution: "@vitest/pretty-format@npm:4.1.7" dependencies: - "@vitest/utils": "npm:3.2.4" + tinyrainbow: "npm:^3.1.0" + checksum: 10/79c86c39173577250955744c3444d8c0c9304c95c7d351b91a916229252c3733a0e969741a8f3441a5c4777b5a4371707ecb747ea4bfd2c07e72ddf1ef621293 + languageName: node + linkType: hard + +"@vitest/runner@npm:4.1.7": + version: 4.1.7 + resolution: "@vitest/runner@npm:4.1.7" + dependencies: + "@vitest/utils": "npm:4.1.7" pathe: "npm:^2.0.3" - strip-literal: "npm:^3.0.0" - checksum: 10/197bd55def519ef202f990b7c1618c212380831827c116240871033e4973decb780503c705ba9245a12bd8121f3ac4086ffcb3e302148b62d9bd77fd18dd1deb + checksum: 10/429f1e0cc93f66a681d8acc816e21ac41258b07550f9139d004aab103bb06be53e3d91fc66886cef1ba1460a120f5fe4b12d6fe32dafdb1b06740dd119d70f7e languageName: node linkType: hard -"@vitest/snapshot@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/snapshot@npm:3.2.4" +"@vitest/snapshot@npm:4.1.7": + version: 4.1.7 + resolution: "@vitest/snapshot@npm:4.1.7" dependencies: - "@vitest/pretty-format": "npm:3.2.4" - magic-string: "npm:^0.30.17" + "@vitest/pretty-format": "npm:4.1.7" + "@vitest/utils": "npm:4.1.7" + magic-string: "npm:^0.30.21" pathe: "npm:^2.0.3" - checksum: 10/acfb682491b9ca9345bf9fed02c2779dec43e0455a380c1966b0aad8dd81c79960902cf34621ab48fe80a0eaf8c61cc42dec186a1321dc3c9897ef2ebd5f1bc4 + checksum: 10/ef7001add6724c025772891616338e6081ecdb11a92c084ca1d09c4662cf632e5877bec4cb38056aabc311f29fbe149c89fbf332975829087f3817554fe92cde languageName: node linkType: hard @@ -6729,6 +6759,13 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@npm:4.1.7": + version: 4.1.7 + resolution: "@vitest/spy@npm:4.1.7" + checksum: 10/49a9959c615f45ec593379a6d1a238190d08524857a6c4819b724134ce8a1a96d94e20144723d245941ce1ada54d8b00552573810d629880ecb8c3ff03b6d1ad + languageName: node + linkType: hard + "@vitest/utils@npm:3.2.4": version: 3.2.4 resolution: "@vitest/utils@npm:3.2.4" @@ -6740,6 +6777,17 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:4.1.7": + version: 4.1.7 + resolution: "@vitest/utils@npm:4.1.7" + dependencies: + "@vitest/pretty-format": "npm:4.1.7" + convert-source-map: "npm:^2.0.0" + tinyrainbow: "npm:^3.1.0" + checksum: 10/9cc729618dade24de3ad6862c288c22e9daac3fda5cae0abc9b6ce87035cc8e7efa2b66c3c124ae08beef462b36761b062e792bbc619798b832a7ea9382ed12a + languageName: node + linkType: hard + "@wojtekmaj/date-utils@npm:^1.1.3, @wojtekmaj/date-utils@npm:^1.5.0": version: 1.5.1 resolution: "@wojtekmaj/date-utils@npm:1.5.1" @@ -7884,13 +7932,6 @@ __metadata: languageName: node linkType: hard -"cac@npm:^6.7.14": - version: 6.7.14 - resolution: "cac@npm:6.7.14" - checksum: 10/002769a0fbfc51c062acd2a59df465a2a947916b02ac50b56c69ec6c018ee99ac3e7f4dd7366334ea847f1ecacf4defaa61bcd2ac283db50156ce1f1d8c8ad42 - languageName: node - linkType: hard - "cac@npm:^7.0.0": version: 7.0.0 resolution: "cac@npm:7.0.0" @@ -8036,6 +8077,13 @@ __metadata: languageName: node linkType: hard +"chai@npm:^6.2.2": + version: 6.2.2 + resolution: "chai@npm:6.2.2" + checksum: 10/13cda42cc40aa46da04a41cf7e5c61df6b6ae0b4e8a8c8b40e04d6947e4d7951377ea8c14f9fa7fe5aaa9e8bd9ba414f11288dc958d4cee6f5221b9436f2778f + languageName: node + linkType: hard + "chalk@npm:*, chalk@npm:^5.3.0": version: 5.3.0 resolution: "chalk@npm:5.3.0" @@ -8756,7 +8804,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:^4, debug@npm:^4.4.1, debug@npm:^4.4.3": +"debug@npm:^4, debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" dependencies: @@ -9628,10 +9676,10 @@ __metadata: languageName: node linkType: hard -"es-module-lexer@npm:^1.7.0": - version: 1.7.0 - resolution: "es-module-lexer@npm:1.7.0" - checksum: 10/b6f3e576a3fed4d82b0d0ad4bbf6b3a5ad694d2e7ce8c4a069560da3db6399381eaba703616a182b16dde50ce998af64e07dcf49f2ae48153b9e07be3f107087 +"es-module-lexer@npm:^2.0.0": + version: 2.1.0 + resolution: "es-module-lexer@npm:2.1.0" + checksum: 10/554c4374e78a812a1fa3673871ce7d42236438c414ea80c2ec35521cd9bb26d1d9155287529057d07431fd91df50d6a26d9bee5afd755fb7f6f7c81905a03956 languageName: node linkType: hard @@ -9810,7 +9858,7 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0, esbuild@npm:^0.27.0": +"esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0": version: 0.27.4 resolution: "esbuild@npm:0.27.4" dependencies: @@ -10431,7 +10479,7 @@ __metadata: languageName: node linkType: hard -"expect-type@npm:^1.2.1": +"expect-type@npm:^1.3.0": version: 1.3.0 resolution: "expect-type@npm:1.3.0" checksum: 10/a5fada3d0c621649261f886e7d93e6bf80ce26d8a86e5d517e38301b8baec8450ab2cb94ba6e7a0a6bf2fc9ee55f54e1b06938ef1efa52ddcfeffbfa01acbbcc @@ -12996,13 +13044,6 @@ __metadata: languageName: node linkType: hard -"js-tokens@npm:^9.0.1": - version: 9.0.1 - resolution: "js-tokens@npm:9.0.1" - checksum: 10/3288ba73bb2023adf59501979fb4890feb6669cc167b13771b226814fde96a1583de3989249880e3f4d674040d1815685db9a9880db9153307480d39dc760365 - languageName: node - linkType: hard - "js-yaml@npm:^3.13.1, js-yaml@npm:^3.6.1": version: 3.14.1 resolution: "js-yaml@npm:3.14.1" @@ -13789,7 +13830,7 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.0, magic-string@npm:^0.30.17": +"magic-string@npm:^0.30.0, magic-string@npm:^0.30.21": version: 0.30.21 resolution: "magic-string@npm:0.30.21" dependencies: @@ -15356,7 +15397,7 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.5.3, postcss@npm:^8.5.6, postcss@npm:^8.5.8": +"postcss@npm:^8.5.3, postcss@npm:^8.5.8": version: 8.5.8 resolution: "postcss@npm:8.5.8" dependencies: @@ -16547,7 +16588,7 @@ __metadata: languageName: node linkType: hard -"rollup@npm:^4.34.9, rollup@npm:^4.43.0": +"rollup@npm:^4.34.9": version: 4.59.0 resolution: "rollup@npm:4.59.0" dependencies: @@ -17347,10 +17388,10 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.9.0": - version: 3.10.0 - resolution: "std-env@npm:3.10.0" - checksum: 10/19c9cda4f370b1ffae2b8b08c72167d8c3e5cfa972aaf5c6873f85d0ed2faa729407f5abb194dc33380708c00315002febb6f1e1b484736bfcf9361ad366013a +"std-env@npm:^4.0.0-rc.1": + version: 4.1.0 + resolution: "std-env@npm:4.1.0" + checksum: 10/008146cdb834010383138d356e0dd3e3b0ac127a8229f711b8c518bb22940813cc0dcd654fc76b17f0b18179f56089f8b8e52bd6a7ffa0041a966581e7a44dbe languageName: node linkType: hard @@ -17671,15 +17712,6 @@ __metadata: languageName: node linkType: hard -"strip-literal@npm:^3.0.0": - version: 3.1.0 - resolution: "strip-literal@npm:3.1.0" - dependencies: - js-tokens: "npm:^9.0.1" - checksum: 10/6eb00906a1c343a1050579d1d6023e067a2d72152edb92e64cad49535115beb2e77905ace24aa459f29b66e75edba75ef9d8eca90575b0322640d64a5d37e131 - languageName: node - linkType: hard - "styled-jsx@npm:5.1.6": version: 5.1.6 resolution: "styled-jsx@npm:5.1.6" @@ -18041,13 +18073,6 @@ __metadata: languageName: node linkType: hard -"tinyexec@npm:^0.3.2": - version: 0.3.2 - resolution: "tinyexec@npm:0.3.2" - checksum: 10/b9d5fed3166fb1acd1e7f9a89afcd97ccbe18b9c1af0278e429455f6976d69271ba2d21797e7c36d57d6b05025e525d2882d88c2ab435b60d1ddf2fea361de57 - languageName: node - linkType: hard - "tinyexec@npm:^1.0.1": version: 1.0.1 resolution: "tinyexec@npm:1.0.1" @@ -18055,6 +18080,13 @@ __metadata: languageName: node linkType: hard +"tinyexec@npm:^1.0.2": + version: 1.2.3 + resolution: "tinyexec@npm:1.2.3" + checksum: 10/067ba5a28221db1a147baf23ca443102afda0fab120067c28cc65f2629b629283b6faf00e47440b72c4bdda940763fa691918b9ebf547da9be7aa4b9a798a930 + languageName: node + linkType: hard + "tinyexec@npm:^1.1.1": version: 1.1.1 resolution: "tinyexec@npm:1.1.1" @@ -18072,7 +18104,7 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.14, tinyglobby@npm:^0.2.15": +"tinyglobby@npm:^0.2.15": version: 0.2.15 resolution: "tinyglobby@npm:0.2.15" dependencies: @@ -18092,13 +18124,6 @@ __metadata: languageName: node linkType: hard -"tinypool@npm:^1.1.1": - version: 1.1.1 - resolution: "tinypool@npm:1.1.1" - checksum: 10/0d54139e9dbc6ef33349768fa78890a4d708d16a7ab68e4e4ef3bb740609ddf0f9fd13292c2f413fbba756166c97051a657181c8f7ae92ade690604f183cc01d - languageName: node - linkType: hard - "tinyrainbow@npm:^2.0.0": version: 2.0.0 resolution: "tinyrainbow@npm:2.0.0" @@ -18106,6 +18131,13 @@ __metadata: languageName: node linkType: hard +"tinyrainbow@npm:^3.1.0": + version: 3.1.0 + resolution: "tinyrainbow@npm:3.1.0" + checksum: 10/4c2c01dde1e5bb9a74973daaae141d4d733d246280b2f9a7f6a9e7dd8e940d48b2580a6086125278777897bc44635d6ccec5f9f563c2179dd2129f4542d0ec05 + languageName: node + linkType: hard + "tinyspy@npm:^4.0.3": version: 4.0.4 resolution: "tinyspy@npm:4.0.4" @@ -19153,21 +19185,6 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:3.2.4": - version: 3.2.4 - resolution: "vite-node@npm:3.2.4" - dependencies: - cac: "npm:^6.7.14" - debug: "npm:^4.4.1" - es-module-lexer: "npm:^1.7.0" - pathe: "npm:^2.0.3" - vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0" - bin: - vite-node: vite-node.mjs - checksum: 10/343244ecabbab3b6e1a3065dabaeefa269965a7a7c54652d4b7a7207ee82185e887af97268c61755dcb2dd6a6ce5d9e114400cbd694229f38523e935703cc62f - languageName: node - linkType: hard - "vite-plugin-svgr@npm:^4.5.0": version: 4.5.0 resolution: "vite-plugin-svgr@npm:4.5.0" @@ -19181,22 +19198,22 @@ __metadata: languageName: node linkType: hard -"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0": - version: 7.3.1 - resolution: "vite@npm:7.3.1" +"vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0, vite@npm:^8.0.14": + version: 8.0.14 + resolution: "vite@npm:8.0.14" dependencies: - esbuild: "npm:^0.27.0" - fdir: "npm:^6.5.0" fsevents: "npm:~2.3.3" - picomatch: "npm:^4.0.3" - postcss: "npm:^8.5.6" - rollup: "npm:^4.43.0" - tinyglobby: "npm:^0.2.15" + lightningcss: "npm:^1.32.0" + picomatch: "npm:^4.0.4" + postcss: "npm:^8.5.15" + rolldown: "npm:1.0.2" + tinyglobby: "npm:^0.2.16" peerDependencies: "@types/node": ^20.19.0 || >=22.12.0 + "@vitejs/devtools": ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 jiti: ">=1.21.0" less: ^4.0.0 - lightningcss: ^1.21.0 sass: ^1.70.0 sass-embedded: ^1.70.0 stylus: ">=0.54.8" @@ -19210,12 +19227,14 @@ __metadata: peerDependenciesMeta: "@types/node": optional: true + "@vitejs/devtools": + optional: true + esbuild: + optional: true jiti: optional: true less: optional: true - lightningcss: - optional: true sass: optional: true sass-embedded: @@ -19232,7 +19251,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10/62e48ffa4283b688f0049005405a004447ad38ffc99a0efea4c3aa9b7eed739f7402b43f00668c0ee5a895b684dc953d62f0722d8a92c5b2f6c95f051bceb208 + checksum: 10/3747c9b9dabdfa5b840630c39b2c764afb3c3762816f3148afe7d516edc1889b60b666adeb4e98761c26fb8ed5ba3a9770df5c0450443daf4cdfac110bc6df1c languageName: node linkType: hard @@ -19291,106 +19310,59 @@ __metadata: languageName: node linkType: hard -"vite@npm:^8.0.14": - version: 8.0.14 - resolution: "vite@npm:8.0.14" - dependencies: - fsevents: "npm:~2.3.3" - lightningcss: "npm:^1.32.0" - picomatch: "npm:^4.0.4" - postcss: "npm:^8.5.15" - rolldown: "npm:1.0.2" - tinyglobby: "npm:^0.2.16" - peerDependencies: - "@types/node": ^20.19.0 || >=22.12.0 - "@vitejs/devtools": ^0.1.18 - esbuild: ^0.27.0 || ^0.28.0 - jiti: ">=1.21.0" - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: ">=0.54.8" - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - dependenciesMeta: - fsevents: - optional: true - peerDependenciesMeta: - "@types/node": - optional: true - "@vitejs/devtools": - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - bin: - vite: bin/vite.js - checksum: 10/3747c9b9dabdfa5b840630c39b2c764afb3c3762816f3148afe7d516edc1889b60b666adeb4e98761c26fb8ed5ba3a9770df5c0450443daf4cdfac110bc6df1c - languageName: node - linkType: hard - -"vitest@npm:^3.1.4": - version: 3.2.4 - resolution: "vitest@npm:3.2.4" - dependencies: - "@types/chai": "npm:^5.2.2" - "@vitest/expect": "npm:3.2.4" - "@vitest/mocker": "npm:3.2.4" - "@vitest/pretty-format": "npm:^3.2.4" - "@vitest/runner": "npm:3.2.4" - "@vitest/snapshot": "npm:3.2.4" - "@vitest/spy": "npm:3.2.4" - "@vitest/utils": "npm:3.2.4" - chai: "npm:^5.2.0" - debug: "npm:^4.4.1" - expect-type: "npm:^1.2.1" - magic-string: "npm:^0.30.17" +"vitest@npm:^4.1.7": + version: 4.1.7 + resolution: "vitest@npm:4.1.7" + dependencies: + "@vitest/expect": "npm:4.1.7" + "@vitest/mocker": "npm:4.1.7" + "@vitest/pretty-format": "npm:4.1.7" + "@vitest/runner": "npm:4.1.7" + "@vitest/snapshot": "npm:4.1.7" + "@vitest/spy": "npm:4.1.7" + "@vitest/utils": "npm:4.1.7" + es-module-lexer: "npm:^2.0.0" + expect-type: "npm:^1.3.0" + magic-string: "npm:^0.30.21" + obug: "npm:^2.1.1" pathe: "npm:^2.0.3" - picomatch: "npm:^4.0.2" - std-env: "npm:^3.9.0" + picomatch: "npm:^4.0.3" + std-env: "npm:^4.0.0-rc.1" tinybench: "npm:^2.9.0" - tinyexec: "npm:^0.3.2" - tinyglobby: "npm:^0.2.14" - tinypool: "npm:^1.1.1" - tinyrainbow: "npm:^2.0.0" - vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0" - vite-node: "npm:3.2.4" + tinyexec: "npm:^1.0.2" + tinyglobby: "npm:^0.2.15" + tinyrainbow: "npm:^3.1.0" + vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0" why-is-node-running: "npm:^2.3.0" peerDependencies: "@edge-runtime/vm": "*" - "@types/debug": ^4.1.12 - "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 - "@vitest/browser": 3.2.4 - "@vitest/ui": 3.2.4 + "@opentelemetry/api": ^1.9.0 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.1.7 + "@vitest/browser-preview": 4.1.7 + "@vitest/browser-webdriverio": 4.1.7 + "@vitest/coverage-istanbul": 4.1.7 + "@vitest/coverage-v8": 4.1.7 + "@vitest/ui": 4.1.7 happy-dom: "*" jsdom: "*" + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: "@edge-runtime/vm": optional: true - "@types/debug": + "@opentelemetry/api": optional: true "@types/node": optional: true - "@vitest/browser": + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": + optional: true + "@vitest/coverage-istanbul": + optional: true + "@vitest/coverage-v8": optional: true "@vitest/ui": optional: true @@ -19398,9 +19370,11 @@ __metadata: optional: true jsdom: optional: true + vite: + optional: false bin: vitest: vitest.mjs - checksum: 10/f10bbce093ecab310ecbe484536ef4496fb9151510b2be0c5907c65f6d31482d9c851f3182531d1d27d558054aa78e8efd9d4702ba6c82058657e8b6a52507ee + checksum: 10/23ce0ce8bf81856c1acf983c6138efda5d01b60cbdc5734abd0948f3b39cde14ea7bf0981a2ec8a6b05fe7f3658b211116997fd658fcd20c2f5740b5465502ca languageName: node linkType: hard