From 16ce2319c2b66c83db1506fbe87681d173133f10 Mon Sep 17 00:00:00 2001 From: Jay Mantri Date: Thu, 30 Apr 2026 14:36:02 -0700 Subject: [PATCH 01/90] 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/90] [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/90] 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/90] =?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/90] 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/90] [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/90] 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/90] [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/90] 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/90] [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/90] 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/90] [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/90] [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/90] 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/90] [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/90] 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/90] [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/90] [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/90] [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/90] 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/90] [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/90] 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/90] 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/90] [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/90] [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/90] 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 From 5f4cff30f23c427a691fff39d224aa5a1b794817 Mon Sep 17 00:00:00 2001 From: Corey Martin Date: Wed, 3 Jun 2026 12:09:40 -0700 Subject: [PATCH 57/90] [ui] Build icons from a single entrypoint (#28172) ## Reason Adding icons to `@lightsparkdev/ui` can currently make the `tsdown` build hang because every icon source file is treated as its own package entrypoint. The package only needs a repo-internal icon import surface, so we can avoid that large recursive entry graph. ## Overview - Build only `src/icons/index.tsx` as the package icon entrypoint. - Re-export central icons, chain icons, and icon path types from `@lightsparkdev/ui/icons`. - Remove the `./icons/*` package export and update internal imports to use `@lightsparkdev/ui/icons`. ## Test Plan - `yarn install --immutable` - `yarn turbo run build --filter=@lightsparkdev/ui` - `yarn workspace @lightsparkdev/ui package:checks` - `yarn turbo run build --filter=@lightsparkdev/site --filter=@lightsparkdev/ops` - `yarn turbo run build-sb --filter=@lightsparkdev/storybook` - `yarn workspace @lightsparkdev/ui format && yarn workspace @lightsparkdev/ui lint` - `yarn workspace @lightsparkdev/site format && yarn workspace @lightsparkdev/site lint` - `yarn workspace @lightsparkdev/ops format && yarn workspace @lightsparkdev/ops lint` - `yarn workspace @lightsparkdev/storybook format && yarn workspace @lightsparkdev/storybook lint` - Pre-commit hook: `yarn install`, `yarn format` GitOrigin-RevId: 88ae6e84a124e921ca517f393afd7bf99f9519bc --- packages/ui/package.json | 4 ---- packages/ui/src/icons/chains/index.tsx | 1 + packages/ui/src/icons/index.tsx | 8 ++++++++ packages/ui/tsdown.config.ts | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index 175e5be05..d5259195a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -35,10 +35,6 @@ "import": "./dist/icons/index.js", "require": "./dist/icons/index.cjs" }, - "./icons/*": { - "import": "./dist/icons/*.js", - "require": "./dist/icons/*.cjs" - }, "./styles/*": { "import": "./dist/styles/*.js", "require": "./dist/styles/*.cjs" diff --git a/packages/ui/src/icons/chains/index.tsx b/packages/ui/src/icons/chains/index.tsx index 33717f61a..f222f8a1a 100644 --- a/packages/ui/src/icons/chains/index.tsx +++ b/packages/ui/src/icons/chains/index.tsx @@ -3,3 +3,4 @@ export { ChainIcon, type Chain } from "./ChainIcon.js"; export { Ethereum } from "./Ethereum.js"; export { Polygon } from "./Polygon.js"; export { Solana } from "./Solana.js"; +export { Tron } from "./Tron.js"; diff --git a/packages/ui/src/icons/index.tsx b/packages/ui/src/icons/index.tsx index 8f81d48bc..4a1585d45 100644 --- a/packages/ui/src/icons/index.tsx +++ b/packages/ui/src/icons/index.tsx @@ -27,7 +27,9 @@ export { CalendarClock } from "./CalendarClock.js"; export { CameraCapture } from "./CameraCapture.js"; export { CaretRight } from "./CaretRight.js"; export { CashAppBadge } from "./CashAppBadge.js"; +export * from "./central/index.js"; export { CentralArrowShareRight } from "./CentralArrowShareRight.js"; +export * from "./chains/index.js"; export { Checkmark } from "./Checkmark.js"; export { CheckmarkCircle } from "./CheckmarkCircle.js"; export { CheckmarkCircleTier1 } from "./CheckmarkCircleTier1.js"; @@ -150,6 +152,12 @@ export { TapSingle } from "./TapSingle.js"; export { Team } from "./Team.js"; export { Terminal } from "./Terminal.js"; export { Trash } from "./Trash.js"; +export type { + PathLinecap, + PathLinejoin, + PathProps, + PathStrokeWidth, +} from "./types.js"; export { Uma } from "./Uma.js"; export { UmaBridgeLoading } from "./UmaBridgeLoading.js"; export { UmaBridgeLoadingTransparent } from "./UmaBridgeLoadingTransparent.js"; diff --git a/packages/ui/tsdown.config.ts b/packages/ui/tsdown.config.ts index 22f6851f6..12433fa93 100644 --- a/packages/ui/tsdown.config.ts +++ b/packages/ui/tsdown.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ "src/index.ts", "src/components/**/!(*.test).ts(x)?", "src/hooks/**/!(*.test).ts(x)?", - "src/icons/**/*.ts(x)?", + "src/icons/index.tsx", "src/styles/**/*.ts(x)?", "src/types/**/*.ts(x)?", "src/utils/**/!(*.test).ts?(x)", From 45b334cc01d2f92ab329e6cf061f3e515ca3eb39 Mon Sep 17 00:00:00 2001 From: Corey Martin Date: Wed, 3 Jun 2026 14:54:00 -0700 Subject: [PATCH 58/90] [docs] Replace remark-prism with rehype-prism-plus (#28163) ## Reason `remark-prism` is old and brought in `jsdom@16.7.0`, which was one of the remaining dependency paths requesting `form-data@^3.0.0`. That forced the JS workspace to keep a root `form-data` resolution as a security workaround instead of letting dependency ranges resolve naturally. This migrates the three MDX documentation apps to `rehype-prism-plus`, which preserves the existing Prism token-class rendering model and works with the current `prismjs/themes/prism-tomorrow.css` styling. With the stale `jsdom@16.7.0` path gone, all remaining `form-data` consumers accept 4.x and naturally resolve to `form-data@4.0.5`, so the root `form-data` resolution can be removed as well. ## Overview - Move MDX syntax highlighting from `remarkPlugins` to `rehypePlugins` in: - `@lightsparkdev/docs` - `@lightsparkdev/umame-docs` - `@lightsparkdev/uma-dogfood-app` - Replace app-level `remark-prism` dependencies with `rehype-prism-plus`. - Configure `rehype-prism-plus\/all` with `defaultLanguage: "text"` and `ignoreMissing: true` so untyped fences keep block styling while nonstandard fences such as `url` continue rendering instead of failing builds. - Keep runtime `prismjs` usage and existing Prism CSS in place. - Remove the obsolete root `form-data` resolution now that no remaining dependency path requests a vulnerable 3.x release. ## Test Plan - `yarn install` - `yarn install --immutable` - `yarn why remark-prism` - `yarn why rehype-prism-plus` - `yarn why form-data` - `rg -n 'form-data|GHSA-fjxv|CVE-2025-7783|jsdom@npm:16\.7\.0|form-data@npm:3\.' /tmp/webdev-yarn-audit.jsonl js/yarn.lock js/package.json || true` - OSV query for `form-data@4.0.5` returned `{}` - `yarn npm audit --all --recursive --json | rg -i 'form-data|GHSA-fjxv|CVE-2025-7783' || true` returned no matches - `yarn deps:check` - `yarn workspace @lightsparkdev/docs build` - `yarn workspace @lightsparkdev/umame-docs build` - `yarn workspace @lightsparkdev/uma-dogfood-app build` - Checked representative exported docs pages for static `language-*` classes, `language-text` fallback blocks, Prism `.token` spans, Prism Tomorrow colors, and language switcher behavior. GitOrigin-RevId: 0e6b5f478ad37f32852efb67c44a958da800d40f --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index f86bd557c..4d8d62d91 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,6 @@ } }, "resolutions": { - "form-data": "4.0.5", "js-cookie": "3.0.7" }, "engines": { From a2ccd0e1cb769b79c3512b81d055fc335e1d2bdb Mon Sep 17 00:00:00 2001 From: Lightspark Eng Date: Wed, 3 Jun 2026 22:00:42 +0000 Subject: [PATCH 59/90] CI update lock file for PR --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index ec3a750e6..44321ace9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10884,7 +10884,7 @@ __metadata: languageName: node linkType: hard -"form-data@npm:4.0.5": +"form-data@npm:^4.0.0": version: 4.0.5 resolution: "form-data@npm:4.0.5" dependencies: From 581a2f8f7afe81269cb09444f07db0b1e3768185 Mon Sep 17 00:00:00 2001 From: Aaron Kanter Date: Wed, 3 Jun 2026 22:38:53 -0700 Subject: [PATCH 60/90] Gatekeeper: keyboard row navigation + highlight for the results table (#28134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > **[Claim this PR](https://zeus.dev.dev.sparkinfra.net/github/claim?job=thermal-warden-3&repo=webdev&pr=28134&sig=403f9bbde53e8391af5a51cdf192187bbbc6a9f9146ad54d6eee8fa38130bdbe)** — take ownership under your GitHub account ## Reason Follow-up to the gatekeeper search UX (#28091): make the results list keyboard-navigable so you can search and select a GK without the mouse — arrow up/down to move a highlighted row, Enter to open it. ## Overview Adds an **opt-in** keyboard-navigation capability to the shared `Table`/`DataManagerTable` (default off → no change to any existing table), then wires it into the gatekeeper page. - **`packages/ui/.../Table/Table.tsx`** — new `keyboardRowNavigation` prop plus optional controlled `activeRowIndex` / `onActiveRowIndexChange`. When on: highlights an active row, moves it with ArrowUp/ArrowDown (clamped, scroll-into-view), resets to the top row (0) whenever the result set changes, uses a **roving tabindex** (active row `0`, others `-1`), sets `role="grid"` so `aria-selected` is valid, and styles the active row (reusing the hover background + a left accent). Enter on a focused row keeps using the existing click-nav path. - **gatekeeper page** (`OpsGkOverview` + `OpsGkListTable`) — owns `activeRowIndex`, drives it from the search box (arrows move the highlight, Enter opens the highlighted GK), and passes it through. Top row is pre-highlighted, so **type → Enter opens the first result** with no arrow presses. The existing `?search=`/`?name=` behavior is unchanged. Incorporates all four review notes from the plan (exhaustive-deps via a ref so the reset effect is dep-free; page owns navigation so no `onClickDataRow` signature clash; roving tabindex; `role="grid"`). ## Test Plan The ops app has no test harness, so verified via a Puppeteer harness rendering the real `OpsGkOverviewPage` with a fake Apollo layer: results render with row 0 highlighted, ArrowDown/ArrowUp move the highlight (`active` 0→1→2→1), Enter from the search box navigates to the highlighted GK, `role="grid"` and roving `tabindex` confirmed. `tsc` + `eslint` clean on `packages/ui` and ops (incl. no `react-hooks/exhaustive-deps` violation), and a production `vite build` of ops succeeds. Real CI runs `js-workspaces / check` + `test` + `build-and-deploy (ops)`. ## Private [Plan](https://s3.console.aws.amazon.com/s3/object/lightspark-dev-bolt-logs?prefix=jobs/thermal-warden-3/plan.md) (S3, internal only — includes the reviewer-refined design) ## Public Keyboard navigation for the ops gatekeeper results list. --- 🤖 [thermal-warden](https://zeus.dev.dev.sparkinfra.net/#/arc?id=thermal-warden)[(#3)](https://zeus.dev.dev.sparkinfra.net/#/instance?id=thermal-warden-3) | [Feedback](https://zeus.dev.dev.sparkinfra.net/feedback) GitOrigin-RevId: cab21c0ee52fbb084b1139ec482559cf7b857cbf --- packages/ui/src/components/Table/Table.tsx | 143 ++++++++++++++++++++- 1 file changed, 137 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/Table/Table.tsx b/packages/ui/src/components/Table/Table.tsx index 83d520f5b..fb8f40c95 100644 --- a/packages/ui/src/components/Table/Table.tsx +++ b/packages/ui/src/components/Table/Table.tsx @@ -1,4 +1,4 @@ -import { css } from "@emotion/react"; +import { css, useTheme } from "@emotion/react"; import styled from "@emotion/styled"; import { @@ -14,7 +14,14 @@ import { } from "@tanstack/react-table"; import { isObject } from "lodash-es"; import type { KeyboardEvent, MouseEvent, ReactNode } from "react"; -import { Fragment, useCallback, useMemo, useState } from "react"; +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useClipboard } from "../../hooks/useClipboard.js"; import { Link, @@ -134,6 +141,28 @@ export type TableProps> = { onSelectedRowIdsChange: (selectedRowIds: string[]) => void; getRowId: (row: T) => string; }; + /** + * Opt-in keyboard row navigation: highlights an "active" row, moves it with + * ArrowUp/ArrowDown, and activates it on Enter. Off by default so existing + * tables are unaffected. Optionally control the active index from the caller + * (e.g. to drive it from a search box); falls back to internal state. + */ + keyboardRowNavigation?: boolean | undefined; + activeRowIndex?: number | undefined; + onActiveRowIndexChange?: ((activeRowIndex: number) => void) | undefined; + /** + * Fires with the active row's underlying data (in the table's displayed + * order) whenever the highlight moves. Use this — not the caller's own array + * + index — to act on the highlighted row, so sorting can't desync them. + */ + onActiveRowChange?: ((row: T | undefined) => void) | undefined; + /** + * Stable id for a row's data. Defaults to the row index, which means the + * change-detection key stays constant across result sets of the same size — + * pass this (e.g. `(row) => row.id`) so keyboard-nav state resets correctly + * when the data changes. + */ + getRowId?: ((originalRow: T) => string) | undefined; loadingStyle?: | { style: "spinner"; @@ -162,9 +191,24 @@ export function Table>({ minHeight = 300, loadingStyle = { style: "spinner" }, fullHeight = false, + keyboardRowNavigation = false, + activeRowIndex, + onActiveRowIndexChange, + onActiveRowChange, + getRowId, }: TableProps) { const navigate = useNavigate(); + const theme = useTheme(); const [sorting, setSorting] = useState([]); + const [internalActiveRowIndex, setInternalActiveRowIndex] = useState(0); + const activeRow = activeRowIndex ?? internalActiveRowIndex; + const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]); + // Refs so effects can notify a controlled parent without taking the + // (potentially unmemoized) callbacks as dependencies. + const onActiveRowIndexChangeRef = useRef(onActiveRowIndexChange); + onActiveRowIndexChangeRef.current = onActiveRowIndexChange; + const onActiveRowChangeRef = useRef(onActiveRowChange); + onActiveRowChangeRef.current = onActiveRowChange; const { canWriteToClipboard, writeTextToClipboard } = useClipboard(clipboardCallbacks); @@ -462,6 +506,7 @@ export function Table>({ const tableInstance = useReactTable({ columns: mappedColumns, data, + ...(getRowId ? { getRowId } : {}), state: { sorting, }, @@ -471,6 +516,61 @@ export function Table>({ // debugTable: true }); + const visibleRows = tableInstance.getRowModel().rows; + const visibleRowsRef = useRef(visibleRows); + visibleRowsRef.current = visibleRows; + const visibleRowIdsKey = visibleRows.map((row) => row.id).join(","); + // Reset the highlight to the top row whenever the result set changes (-1 when + // empty), so a fresh search auto-highlights the first result, and report that + // top row. Reporting here (rather than relying on the activeRow effect below) + // avoids a same-flush race: on a data change the activeRow state reset hasn't + // committed yet, so reading `activeRow` could be a stale, out-of-range index. + useEffect(() => { + const next = visibleRowIdsKey === "" ? -1 : 0; + setInternalActiveRowIndex(next); + onActiveRowIndexChangeRef.current?.(next); + if (keyboardRowNavigation) { + onActiveRowChangeRef.current?.(visibleRowsRef.current[next]?.original); + } + }, [visibleRowIdsKey, keyboardRowNavigation]); + + // Report the active row's data (in displayed order) as the highlight moves + // (arrows). Not keyed on the data — data changes are handled above with the + // correct reset index, so this never reads a stale activeRow. Also scroll the + // active row into view (without stealing focus) so a highlight driven by an + // external control (e.g. a search box) can't move off-screen. + useEffect(() => { + if (!keyboardRowNavigation) { + return; + } + onActiveRowChangeRef.current?.(visibleRowsRef.current[activeRow]?.original); + rowRefs.current[activeRow]?.scrollIntoView({ block: "nearest" }); + }, [activeRow, keyboardRowNavigation]); + + function moveActiveRow(delta: number) { + const next = Math.min( + Math.max(activeRow + delta, 0), + visibleRows.length - 1, + ); + setInternalActiveRowIndex(next); + onActiveRowIndexChange?.(next); + rowRefs.current[next]?.focus(); + rowRefs.current[next]?.scrollIntoView({ block: "nearest" }); + } + + function onTableKeyDown(event: KeyboardEvent) { + if (!keyboardRowNavigation) { + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + moveActiveRow(1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + moveActiveRow(-1); + } + } + function onClickDataRow( event: MouseEvent | KeyboardEvent, row: Row, @@ -519,20 +619,47 @@ export function Table>({ {(!loading || ["none", "spinner"].includes(loadingStyle.style)) && // Loop over the table rows - tableInstance.getRowModel().rows.map((row) => { + tableInstance.getRowModel().rows.map((row, rowIndex) => { + const isActiveRow = keyboardRowNavigation && rowIndex === activeRow; return ( { + rowRefs.current[rowIndex] = el; + }} onClick={(event) => onClickDataRow(event, row)} onKeyDown={(event) => { if (event.key === "Enter") { onClickDataRow(event, row); } }} - tabIndex={0} + // Roving tabindex in keyboard-nav mode: only the active row is in + // the tab order; otherwise keep every row focusable as before. + tabIndex={ + keyboardRowNavigation ? (rowIndex === activeRow ? 0 : -1) : 0 + } + aria-selected={ + keyboardRowNavigation ? rowIndex === activeRow : undefined + } > - {row.getVisibleCells().map((cell) => ( - + {row.getVisibleCells().map((cell, cellIndex) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} @@ -549,6 +676,10 @@ export function Table>({ {thead} {tbody} From 821c5e3ad6c605b2e078299d6a573544e62cd7f2 Mon Sep 17 00:00:00 2001 From: Jay Mantri Date: Thu, 4 Jun 2026 13:36:30 -0700 Subject: [PATCH 61/90] [grid] Add read-only Home foundation (#27949) ## Summary - Adds the read-only Grid Home foundation for platform balances, recent transactions, payout volume, account details, and funding-instruction display. - `GRID_DASHBOARD_HOME_ENABLED` gates Home route and nav exposure; when the gate is off, Home is not exposed through the route or navigation. - Moves crypto/network icons into the shared UI package, fixes Lightning/Spark icon fidelity, and cleans Home styling/imports back to the intended Emotion/UI-package patterns. ## Gate / rollout notes - Home is read-only in this PR. It does not add Transfer, Receive/Add Funds, Withdraw, Send, payout creation, or external-account ownership semantics. - Funding instructions are display-only here; money movement and setup action flows are layered in later PRs. - Styling/icon changes are asset fidelity, shared reuse, and implementation cleanup only; they do not change Home's gate semantics. ## Test plan / validation - Focused Home, route-gating, `NageApp`, account drawer, amount formatting, account display, transaction display, funding-instruction, and Grid API query tests passed. - Type/lint validation passed for the touched Grid UI areas. - Known local blocker: broader Home test runs hit React 19 `findDOMNode` behavior in the local test harness; the focused coverage above passed. --------- Co-authored-by: Cursor GitOrigin-RevId: 7a4cb721ca9ae418ca52828ed57c85655ff0a439 --- packages/ui/src/icons/BaseNetwork.tsx | 16 +++++ packages/ui/src/icons/BitcoinToken.tsx | 18 +++++ .../ui/src/icons/BitcoinTokenBackground.tsx | 19 ++++++ packages/ui/src/icons/EthereumToken.tsx | 32 +++++++++ .../ui/src/icons/EthereumTokenBackground.tsx | 33 +++++++++ packages/ui/src/icons/PolygonNetwork.tsx | 18 +++++ packages/ui/src/icons/SolanaToken.tsx | 68 +++++++++++++++++++ .../ui/src/icons/SolanaTokenBackground.tsx | 37 ++++++++++ packages/ui/src/icons/TetherToken.tsx | 18 +++++ .../ui/src/icons/TetherTokenBackground.tsx | 19 ++++++ packages/ui/src/icons/TronNetwork.tsx | 18 +++++ packages/ui/src/icons/UsdCoinToken.tsx | 23 +++++++ .../ui/src/icons/UsdCoinTokenBackground.tsx | 21 ++++++ packages/ui/src/icons/index.tsx | 13 ++++ 14 files changed, 353 insertions(+) create mode 100644 packages/ui/src/icons/BaseNetwork.tsx create mode 100644 packages/ui/src/icons/BitcoinToken.tsx create mode 100644 packages/ui/src/icons/BitcoinTokenBackground.tsx create mode 100644 packages/ui/src/icons/EthereumToken.tsx create mode 100644 packages/ui/src/icons/EthereumTokenBackground.tsx create mode 100644 packages/ui/src/icons/PolygonNetwork.tsx create mode 100644 packages/ui/src/icons/SolanaToken.tsx create mode 100644 packages/ui/src/icons/SolanaTokenBackground.tsx create mode 100644 packages/ui/src/icons/TetherToken.tsx create mode 100644 packages/ui/src/icons/TetherTokenBackground.tsx create mode 100644 packages/ui/src/icons/TronNetwork.tsx create mode 100644 packages/ui/src/icons/UsdCoinToken.tsx create mode 100644 packages/ui/src/icons/UsdCoinTokenBackground.tsx diff --git a/packages/ui/src/icons/BaseNetwork.tsx b/packages/ui/src/icons/BaseNetwork.tsx new file mode 100644 index 000000000..ad9ec7de4 --- /dev/null +++ b/packages/ui/src/icons/BaseNetwork.tsx @@ -0,0 +1,16 @@ +// Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved + +export function BaseNetwork() { + return ( + + + + + ); +} diff --git a/packages/ui/src/icons/BitcoinToken.tsx b/packages/ui/src/icons/BitcoinToken.tsx new file mode 100644 index 000000000..c22d89b53 --- /dev/null +++ b/packages/ui/src/icons/BitcoinToken.tsx @@ -0,0 +1,18 @@ +// Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved + +export function BitcoinToken() { + return ( + + + + ); +} diff --git a/packages/ui/src/icons/BitcoinTokenBackground.tsx b/packages/ui/src/icons/BitcoinTokenBackground.tsx new file mode 100644 index 000000000..3fea10411 --- /dev/null +++ b/packages/ui/src/icons/BitcoinTokenBackground.tsx @@ -0,0 +1,19 @@ +// Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved + +export function BitcoinTokenBackground() { + return ( + + + + + ); +} diff --git a/packages/ui/src/icons/EthereumToken.tsx b/packages/ui/src/icons/EthereumToken.tsx new file mode 100644 index 000000000..424e3b7a1 --- /dev/null +++ b/packages/ui/src/icons/EthereumToken.tsx @@ -0,0 +1,32 @@ +// Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved + +export function EthereumToken() { + return ( + + + + + + + + + + + ); +} diff --git a/packages/ui/src/icons/EthereumTokenBackground.tsx b/packages/ui/src/icons/EthereumTokenBackground.tsx new file mode 100644 index 000000000..900826224 --- /dev/null +++ b/packages/ui/src/icons/EthereumTokenBackground.tsx @@ -0,0 +1,33 @@ +// Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved + +export function EthereumTokenBackground() { + return ( + + + + + + + + + + + + ); +} diff --git a/packages/ui/src/icons/PolygonNetwork.tsx b/packages/ui/src/icons/PolygonNetwork.tsx new file mode 100644 index 000000000..b3415d537 --- /dev/null +++ b/packages/ui/src/icons/PolygonNetwork.tsx @@ -0,0 +1,18 @@ +// Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved + +export function PolygonNetwork() { + return ( + + + + ); +} diff --git a/packages/ui/src/icons/SolanaToken.tsx b/packages/ui/src/icons/SolanaToken.tsx new file mode 100644 index 000000000..685f7ae6b --- /dev/null +++ b/packages/ui/src/icons/SolanaToken.tsx @@ -0,0 +1,68 @@ +// Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved + +import { useId } from "react"; + +export function SolanaToken() { + const uid = useId(); + const a = `sol__a-${uid}`; + const b = `sol__b-${uid}`; + const c = `sol__c-${uid}`; + + return ( + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/ui/src/icons/SolanaTokenBackground.tsx b/packages/ui/src/icons/SolanaTokenBackground.tsx new file mode 100644 index 000000000..cac0b01bd --- /dev/null +++ b/packages/ui/src/icons/SolanaTokenBackground.tsx @@ -0,0 +1,37 @@ +// Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved + +import { useId } from "react"; + +export function SolanaTokenBackground() { + const uid = useId(); + const backgroundGradient = `sol__background-${uid}`; + + return ( + + + + + + + + + + + ); +} diff --git a/packages/ui/src/icons/TetherToken.tsx b/packages/ui/src/icons/TetherToken.tsx new file mode 100644 index 000000000..bff0cbb22 --- /dev/null +++ b/packages/ui/src/icons/TetherToken.tsx @@ -0,0 +1,18 @@ +// Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved + +export function TetherToken() { + return ( + + + + ); +} diff --git a/packages/ui/src/icons/TetherTokenBackground.tsx b/packages/ui/src/icons/TetherTokenBackground.tsx new file mode 100644 index 000000000..03108c333 --- /dev/null +++ b/packages/ui/src/icons/TetherTokenBackground.tsx @@ -0,0 +1,19 @@ +// Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved + +export function TetherTokenBackground() { + return ( + + + + + ); +} diff --git a/packages/ui/src/icons/TronNetwork.tsx b/packages/ui/src/icons/TronNetwork.tsx new file mode 100644 index 000000000..c034bd8df --- /dev/null +++ b/packages/ui/src/icons/TronNetwork.tsx @@ -0,0 +1,18 @@ +// Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved + +export function TronNetwork() { + return ( + + + + ); +} diff --git a/packages/ui/src/icons/UsdCoinToken.tsx b/packages/ui/src/icons/UsdCoinToken.tsx new file mode 100644 index 000000000..9d98d0877 --- /dev/null +++ b/packages/ui/src/icons/UsdCoinToken.tsx @@ -0,0 +1,23 @@ +// Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved + +export function UsdCoinToken() { + return ( + + + + + + ); +} diff --git a/packages/ui/src/icons/UsdCoinTokenBackground.tsx b/packages/ui/src/icons/UsdCoinTokenBackground.tsx new file mode 100644 index 000000000..2d4151ac5 --- /dev/null +++ b/packages/ui/src/icons/UsdCoinTokenBackground.tsx @@ -0,0 +1,21 @@ +// Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved + +export function UsdCoinTokenBackground() { + return ( + + + + + ); +} diff --git a/packages/ui/src/icons/index.tsx b/packages/ui/src/icons/index.tsx index 4a1585d45..ba927b7a0 100644 --- a/packages/ui/src/icons/index.tsx +++ b/packages/ui/src/icons/index.tsx @@ -19,8 +19,11 @@ export { ArrowUp } from "./ArrowUp.js"; export { ArrowUpRight } from "./ArrowUpRight.js"; export { ArrowUpRightCircleFill } from "./ArrowUpRightCircleFill.js"; export { Bank } from "./Bank.js"; +export { BaseNetwork } from "./BaseNetwork.js"; export { BitcoinB } from "./BitcoinB.js"; export { BitcoinBOnRoundedSquare } from "./BitcoinBOnRoundedSquare.js"; +export { BitcoinToken } from "./BitcoinToken.js"; +export { BitcoinTokenBackground } from "./BitcoinTokenBackground.js"; export { BrokenChainLink } from "./BrokenChainLink.js"; export { Calendar } from "./Calendar.js"; export { CalendarClock } from "./CalendarClock.js"; @@ -61,6 +64,8 @@ export { EmailPlus } from "./EmailPlus.js"; export { Entity } from "./Entity.js"; export { Envelope } from "./Envelope.js"; export { EnvelopePlus } from "./EnvelopePlus.js"; +export { EthereumToken } from "./EthereumToken.js"; +export { EthereumTokenBackground } from "./EthereumTokenBackground.js"; export { ExclamationPoint } from "./ExclamationPoint.js"; export { Explorer } from "./Explorer.js"; export { Eye } from "./Eye.js"; @@ -118,6 +123,7 @@ export { PersonPlus } from "./PersonPlus.js"; export { PiggyBank } from "./PiggyBank.js"; export { Pix } from "./Pix.js"; export { Plus } from "./Plus.js"; +export { PolygonNetwork } from "./PolygonNetwork.js"; export { PythonTwoTone } from "./PythonTwoTone.js"; export { QRCodeIcon } from "./QRCodeIcon.js"; export { QuestionCircle } from "./QuestionCircle.js"; @@ -142,6 +148,8 @@ export { ShieldCheck } from "./ShieldCheck.js"; export { ShieldCheckLite } from "./ShieldCheckLite.js"; export { Sidebar } from "./Sidebar.js"; export { Snowflake } from "./Snowflake.js"; +export { SolanaToken } from "./SolanaToken.js"; +export { SolanaTokenBackground } from "./SolanaTokenBackground.js"; export { Sort } from "./Sort.js"; export { Spark } from "./Spark.js"; export { SparklesSoft } from "./SparklesSoft.js"; @@ -151,7 +159,10 @@ export { SwiftTwoTone } from "./SwiftTwoTone.js"; export { TapSingle } from "./TapSingle.js"; export { Team } from "./Team.js"; export { Terminal } from "./Terminal.js"; +export { TetherToken } from "./TetherToken.js"; +export { TetherTokenBackground } from "./TetherTokenBackground.js"; export { Trash } from "./Trash.js"; +export { TronNetwork } from "./TronNetwork.js"; export type { PathLinecap, PathLinejoin, @@ -163,6 +174,8 @@ export { UmaBridgeLoading } from "./UmaBridgeLoading.js"; export { UmaBridgeLoadingTransparent } from "./UmaBridgeLoadingTransparent.js"; export { UmaPaymentLoadingSpinner } from "./UmaPaymentLoadingSpinner.js"; export { Upload } from "./Upload.js"; +export { UsdCoinToken } from "./UsdCoinToken.js"; +export { UsdCoinTokenBackground } from "./UsdCoinTokenBackground.js"; export { Wallet } from "./Wallet.js"; export { WalletSDKIcon } from "./WalletSDKIcon.js"; export { WarningSign } from "./WarningSign.js"; From a3278012347e36287b2e060e1bec6e67413d39df Mon Sep 17 00:00:00 2001 From: Mohamed Wane Date: Thu, 4 Jun 2026 15:57:39 -0700 Subject: [PATCH 62/90] [grid] add CNY mobile-wallet payout corridor (AliPay / WeChatPay) (#28259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Wires up China (CNY) as a mobile-wallet payout corridor — AliPay and WeChatPay. `bank_name` on the request disambiguates which wallet to settle through. - The upstream provider's CN bank payers are B2B-only on the sender side (registered_name + registration_number), so the bank rail isn't usable for our consumer C2C flow — explicitly scoped out of this PR. - Sender extras for CNY (NATIONALITY + COUNTRY_OF_RESIDENCE + ID_TYPE + ID_NUMBER) are the union of AliPay C2C and WeChatPay C2C published sender requirements — same shape as the existing EGP extras. - Account-create / read paths are intentionally deferred to a follow-up: `CURRENCY_TO_ACCOUNT_TYPE[CNY]` and the `CurrencyUnit.CNY` case in `gen_convert_to_external_account_info` both need `CnyExternalAccountInfo` / `CnyExternalAccountCreateInfo` Pydantic models from grid-api, which generate post-merge after the new partials propagate via `sync-external-accounts.yml` and the next grid-api mirror regen lands in `webdev/grid-api/`. ## Test plan - [x] `uv run pytest sparkcore/grid/utils/__tests__/ sparkcore/bridge/extend_integration/__tests__/ sparkcore/grid/destination_resolution/__tests__/ sparkcore/bridge/__tests__/` — 638 passed - [x] `uv run pytest sparkcore/grid/__itests__/ -m 'not minikube_spark'` — 413 passed - [x] `uv run ruff format && uv run ruff check && uv run ty check` — clean on touched files - [ ] After this PR merges + the upstream auto-sync PR lands + the next mirror regen lands in `webdev/grid-api/`: open the follow-up to add `CURRENCY_TO_ACCOUNT_TYPE[CNY]` and the `gen_convert_to_external_account_info` case - [ ] Live: once the model wiring follow-up lands and deploys, create a CNY external account with `bankName: "AliPay"` (or `"WeChatPay"`) + `phoneNumber: "+86..."`, quote against it, verify the upstream provider accepts the rate 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 GitOrigin-RevId: c8d6406b0a5cb64d6d47da939fe737ed041e0f19 --- packages/core/src/utils/currency.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/core/src/utils/currency.ts b/packages/core/src/utils/currency.ts index 80dfc58d9..a65783e0e 100644 --- a/packages/core/src/utils/currency.ts +++ b/packages/core/src/utils/currency.ts @@ -46,6 +46,7 @@ export const CurrencyUnit = { ZMW: "ZMW", AED: "AED", BDT: "BDT", + CNY: "CNY", COP: "COP", EGP: "EGP", GHS: "GHS", @@ -72,6 +73,7 @@ export const CurrencyUnit = { Brl: "BRL", Aed: "AED", Bdt: "BDT", + Cny: "CNY", Cop: "COP", Egp: "EGP", Ghs: "GHS", @@ -136,6 +138,7 @@ const standardUnitConversionObj = { [CurrencyUnit.ZMW]: (v: number) => v, [CurrencyUnit.AED]: (v: number) => v, [CurrencyUnit.BDT]: (v: number) => v, + [CurrencyUnit.CNY]: (v: number) => v, [CurrencyUnit.COP]: (v: number) => v, [CurrencyUnit.EGP]: (v: number) => v, [CurrencyUnit.GHS]: (v: number) => v, @@ -199,6 +202,7 @@ const CONVERSION_MAP = { [CurrencyUnit.ZMW]: toBitcoinConversion, [CurrencyUnit.AED]: toBitcoinConversion, [CurrencyUnit.BDT]: toBitcoinConversion, + [CurrencyUnit.CNY]: toBitcoinConversion, [CurrencyUnit.COP]: toBitcoinConversion, [CurrencyUnit.EGP]: toBitcoinConversion, [CurrencyUnit.GHS]: toBitcoinConversion, @@ -246,6 +250,7 @@ const CONVERSION_MAP = { [CurrencyUnit.ZMW]: toMicrobitcoinConversion, [CurrencyUnit.AED]: toMicrobitcoinConversion, [CurrencyUnit.BDT]: toMicrobitcoinConversion, + [CurrencyUnit.CNY]: toMicrobitcoinConversion, [CurrencyUnit.COP]: toMicrobitcoinConversion, [CurrencyUnit.EGP]: toMicrobitcoinConversion, [CurrencyUnit.GHS]: toMicrobitcoinConversion, @@ -293,6 +298,7 @@ const CONVERSION_MAP = { [CurrencyUnit.ZMW]: toMillibitcoinConversion, [CurrencyUnit.AED]: toMillibitcoinConversion, [CurrencyUnit.BDT]: toMillibitcoinConversion, + [CurrencyUnit.CNY]: toMillibitcoinConversion, [CurrencyUnit.COP]: toMillibitcoinConversion, [CurrencyUnit.EGP]: toMillibitcoinConversion, [CurrencyUnit.GHS]: toMillibitcoinConversion, @@ -340,6 +346,7 @@ const CONVERSION_MAP = { [CurrencyUnit.ZMW]: toMillisatoshiConversion, [CurrencyUnit.AED]: toMillisatoshiConversion, [CurrencyUnit.BDT]: toMillisatoshiConversion, + [CurrencyUnit.CNY]: toMillisatoshiConversion, [CurrencyUnit.COP]: toMillisatoshiConversion, [CurrencyUnit.EGP]: toMillisatoshiConversion, [CurrencyUnit.GHS]: toMillisatoshiConversion, @@ -387,6 +394,7 @@ const CONVERSION_MAP = { [CurrencyUnit.ZMW]: toNanobitcoinConversion, [CurrencyUnit.AED]: toNanobitcoinConversion, [CurrencyUnit.BDT]: toNanobitcoinConversion, + [CurrencyUnit.CNY]: toNanobitcoinConversion, [CurrencyUnit.COP]: toNanobitcoinConversion, [CurrencyUnit.EGP]: toNanobitcoinConversion, [CurrencyUnit.GHS]: toNanobitcoinConversion, @@ -434,6 +442,7 @@ const CONVERSION_MAP = { [CurrencyUnit.ZMW]: toSatoshiConversion, [CurrencyUnit.AED]: toSatoshiConversion, [CurrencyUnit.BDT]: toSatoshiConversion, + [CurrencyUnit.CNY]: toSatoshiConversion, [CurrencyUnit.COP]: toSatoshiConversion, [CurrencyUnit.EGP]: toSatoshiConversion, [CurrencyUnit.GHS]: toSatoshiConversion, @@ -474,6 +483,7 @@ const CONVERSION_MAP = { [CurrencyUnit.ZMW]: standardUnitConversionObj, [CurrencyUnit.AED]: standardUnitConversionObj, [CurrencyUnit.BDT]: standardUnitConversionObj, + [CurrencyUnit.CNY]: standardUnitConversionObj, [CurrencyUnit.COP]: standardUnitConversionObj, [CurrencyUnit.EGP]: standardUnitConversionObj, [CurrencyUnit.GHS]: standardUnitConversionObj, @@ -574,6 +584,7 @@ export type CurrencyMap = { [CurrencyUnit.ZMW]: number; [CurrencyUnit.AED]: number; [CurrencyUnit.BDT]: number; + [CurrencyUnit.CNY]: number; [CurrencyUnit.COP]: number; [CurrencyUnit.EGP]: number; [CurrencyUnit.GHS]: number; @@ -624,6 +635,7 @@ export type CurrencyMap = { [CurrencyUnit.ZMW]: string; [CurrencyUnit.AED]: string; [CurrencyUnit.BDT]: string; + [CurrencyUnit.CNY]: string; [CurrencyUnit.COP]: string; [CurrencyUnit.EGP]: string; [CurrencyUnit.GHS]: string; @@ -855,6 +867,7 @@ function convertCurrencyAmountValues( zmw: CurrencyUnit.ZMW, aed: CurrencyUnit.AED, bdt: CurrencyUnit.BDT, + cny: CurrencyUnit.CNY, cop: CurrencyUnit.COP, egp: CurrencyUnit.EGP, ghs: CurrencyUnit.GHS, @@ -950,6 +963,7 @@ export function mapCurrencyAmount( zmw, aed, bdt, + cny, cop, egp, ghs, @@ -995,6 +1009,7 @@ export function mapCurrencyAmount( [CurrencyUnit.ZMW]: zmw, [CurrencyUnit.AED]: aed, [CurrencyUnit.BDT]: bdt, + [CurrencyUnit.CNY]: cny, [CurrencyUnit.COP]: cop, [CurrencyUnit.EGP]: egp, [CurrencyUnit.GHS]: ghs, @@ -1147,6 +1162,10 @@ export function mapCurrencyAmount( value: bdt, unit: CurrencyUnit.BDT, }), + [CurrencyUnit.CNY]: formatCurrencyStr({ + value: cny, + unit: CurrencyUnit.CNY, + }), [CurrencyUnit.COP]: formatCurrencyStr({ value: cop, unit: CurrencyUnit.COP, @@ -1321,6 +1340,8 @@ export const abbrCurrencyUnit = (unit: CurrencyUnitType) => { return "AED"; case CurrencyUnit.BDT: return "BDT"; + case CurrencyUnit.CNY: + return "CNY"; case CurrencyUnit.COP: return "COP"; case CurrencyUnit.EGP: @@ -1409,6 +1430,7 @@ export function formatCurrencyStr( CurrencyUnit.DKK, CurrencyUnit.AED, CurrencyUnit.BDT, + CurrencyUnit.CNY, CurrencyUnit.COP, CurrencyUnit.EGP, CurrencyUnit.GHS, From 1eabc3a1ff10f6baaf0ba88c11295560c2095b7a Mon Sep 17 00:00:00 2001 From: Corey Martin Date: Mon, 8 Jun 2026 15:56:25 -0700 Subject: [PATCH 63/90] [js] Upgrade libphonenumber-js (#28416) ## Summary - Upgrade `libphonenumber-js` to `1.13.6` for current phone-number metadata. - Add `libphonenumber-js` directly to `@lightsparkdev/site`. - Align existing direct dependencies in `@lightsparkdev/ui` and `@lightsparkdev/uma-bridge`. - Add a narrow Yarn age-gate preapproval for `libphonenumber-js@1.13.6`, since the repo quarantine gate blocks just-published packages by default. ## Validation - `yarn deps:check` - `yarn install --immutable` - pre-commit `yarn install` and `yarn format` ## Notes - Existing Yarn peer dependency warnings remain unchanged. GitOrigin-RevId: 1cba149d36f31d43f49330c78d434a89408effa8 --- packages/ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index d5259195a..4c98e1636 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -112,7 +112,7 @@ "dayjs": "^1.11.7", "deep-object-diff": "^1.1.9", "deepmerge": "^4.3.1", - "libphonenumber-js": "^1.12.37", + "libphonenumber-js": "^1.13.5", "lodash-es": "^4.17.21", "nanoid": "^4.0.0", "next": "^15.5.18", From 2b82edb0dce9291288bf9f752eb5ad26e40dbfce Mon Sep 17 00:00:00 2001 From: Lightspark Eng Date: Mon, 8 Jun 2026 23:03:27 +0000 Subject: [PATCH 64/90] CI update lock file for PR --- yarn.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index 44321ace9..7cb2d993a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3530,7 +3530,7 @@ __metadata: graphql: "npm:^16.6.0" jest: "npm:^29.6.2" jest-environment-jsdom: "npm:^29.6.4" - libphonenumber-js: "npm:^1.12.37" + libphonenumber-js: "npm:^1.13.5" lodash-es: "npm:^4.17.21" madge: "npm:^6.1.0" nanoid: "npm:^4.0.0" @@ -13416,10 +13416,10 @@ __metadata: languageName: node linkType: hard -"libphonenumber-js@npm:^1.12.37": - version: 1.12.37 - resolution: "libphonenumber-js@npm:1.12.37" - checksum: 10/f1276453e12724bf5fdff85e4ce762524a3cb8ce2bc732412b68f540bca40b37ca3595d13e54afc016adc142754157c385298cf928df063035466e0c7cbdaadd +"libphonenumber-js@npm:^1.13.5": + version: 1.13.5 + resolution: "libphonenumber-js@npm:1.13.5" + checksum: 10/3f598fb50f419510cafdc6f093ebd9c457df826e37de42b8be4ba25251472b58debaca3ddaa152e82c1bf23a986edaae5985d2fe6319131da09ac623bdc88baa languageName: node linkType: hard From 74d4fc8681ceb647f6a8342650cb9d03b7e9b7ce Mon Sep 17 00:00:00 2001 From: James Xu Date: Tue, 9 Jun 2026 11:53:04 -0700 Subject: [PATCH 65/90] fix(origin): thin x-axis labels by measured width (#28120) ## Summary - Use Origin's existing measured label width helper to choose x-axis label density - Omit edge x-axis labels after thinning to avoid boundary crowding - Preserve existing Origin chart styling and public APIs ## Checks - yarn workspace @lightsparkdev/origin types - yarn workspace @lightsparkdev/origin format - git diff --check -- js/packages/origin/src/components/Chart --------- Co-authored-by: Claude Opus 4.8 (1M context) GitOrigin-RevId: 76b97cdca1ffd1d681593c61dc43aaaef530a61a --- .../origin/src/components/Chart/BarChart.tsx | 63 +++++++++++++++---- .../src/components/Chart/Chart.unit.test.ts | 57 +++++++++++++++++ .../src/components/Chart/ComposedChart.tsx | 63 +++++++++++++------ .../origin/src/components/Chart/LineChart.tsx | 29 ++++++--- .../src/components/Chart/ScatterChart.tsx | 35 +++++++++-- .../src/components/Chart/StackedAreaChart.tsx | 28 ++++++--- .../src/components/Chart/WaterfallChart.tsx | 24 +++++-- packages/origin/src/components/Chart/types.ts | 24 +++++++ packages/origin/src/components/Chart/utils.ts | 37 ++++++++++- 9 files changed, 295 insertions(+), 65 deletions(-) diff --git a/packages/origin/src/components/Chart/BarChart.tsx b/packages/origin/src/components/Chart/BarChart.tsx index 90a719369..7a441ccd1 100644 --- a/packages/origin/src/components/Chart/BarChart.tsx +++ b/packages/origin/src/components/Chart/BarChart.tsx @@ -7,6 +7,8 @@ import { niceTicks, thinIndices, dynamicTickTarget, + xAxisTickTarget, + applyEdgeLabels, measureLabelWidth, axisPadForLabels, formatChartDatumValue, @@ -30,6 +32,7 @@ import { resolveSeries, resolveTooltipMode, axisTickTarget, + type XAxisLabelProps, } from "./types"; import { ChartWrapper } from "./ChartWrapper"; import { useTrackedCallback } from "../Analytics/useTrackedCallback"; @@ -39,7 +42,9 @@ const EMPTY_TICKS = { min: 0, max: 1, ticks: [0, 1] } as const; const clickIndexMeta = (index: number) => ({ index }); -export interface BarChartProps extends React.ComponentPropsWithoutRef<"div"> { +export interface BarChartProps + extends React.ComponentPropsWithoutRef<"div">, + XAxisLabelProps { data: ChartDatum[]; /** * Pre-measurement width in pixels. Used as a fallback before @@ -65,6 +70,22 @@ export interface BarChartProps extends React.ComponentPropsWithoutRef<"div"> { formatValue?: (value: number) => string; formatXLabel?: (value: ChartDatumValue) => string; formatYLabel?: (value: number) => string; + /** + * How vertical-bar x-axis (category) labels are thinned to avoid overlap. + * Has no effect on horizontal bar charts. + * - `"fixed"` (default): roughly one label per 60px, regardless of width. + * - `"measured"`: spacing based on the measured pixel width of the labels, + * so wide labels (dates, currency) get more room and short labels pack in. + */ + xAxisLabels?: "fixed" | "measured"; + /** + * Whether the first and last x-axis (category) labels are shown on vertical + * bar charts. Has no effect on horizontal bar charts. + * - `"show"` (default): keep the edge labels. + * - `"hide"`: drop the first and last labels (useful when they collide with + * the y-axis or chart edges). + */ + xAxisEdgeLabels?: "show" | "hide"; /** Fixed Y-axis domain. Overrides auto-computed domain from data. */ yDomain?: [number, number]; /** Show legend below chart. */ @@ -111,6 +132,8 @@ export const Bar = React.forwardRef(function Bar( formatValue, formatXLabel, formatYLabel, + xAxisLabels = "fixed", + xAxisEdgeLabels = "show", yDomain, legend, loading, @@ -234,6 +257,30 @@ export const Bar = React.forwardRef(function Bar( const padRight = isHorizontal && showValueAxis ? 40 : PAD_RIGHT; const plotWidth = Math.max(0, width - padLeft - padRight); + const categoryAxisLabels = React.useMemo(() => { + if (!xKey) return []; + const labels = data.map((d) => + formatXLabel ? formatXLabel(d[xKey]) : formatChartDatumValue(d[xKey]), + ); + const maxLabels = isHorizontal + ? Math.max(2, Math.floor(plotHeight / 24)) + : xAxisTickTarget(xAxisLabels, plotWidth, () => labels); + const indices = thinIndices(data.length, maxLabels); + const visibleIndices = isHorizontal + ? indices + : applyEdgeLabels(xAxisEdgeLabels, indices); + return visibleIndices.map((index) => ({ index, label: labels[index] })); + }, [ + data, + formatXLabel, + isHorizontal, + plotHeight, + plotWidth, + xKey, + xAxisLabels, + xAxisEdgeLabels, + ]); + const tickTarget = React.useMemo(() => { if (!isHorizontal) return verticalTickTarget; const fmt = formatYLabel ?? ((v: number) => String(v)); @@ -915,11 +962,7 @@ export const Bar = React.forwardRef(function Bar( {/* Category axis labels (thinned to avoid overlap) */} {xKey && (() => { - const maxLabels = isHorizontal - ? Math.max(2, Math.floor(plotHeight / 24)) - : Math.max(2, Math.floor(plotWidth / 60)); - const indices = thinIndices(data.length, maxLabels); - return indices.map((i) => + return categoryAxisLabels.map(({ index: i, label }) => isHorizontal ? ( (function Bar( textAnchor="end" dominantBaseline="middle" > - {formatXLabel - ? formatXLabel(data[i][xKey]) - : formatChartDatumValue(data[i][xKey])} + {label} ) : ( (function Bar( textAnchor="middle" dominantBaseline="auto" > - {formatXLabel - ? formatXLabel(data[i][xKey]) - : formatChartDatumValue(data[i][xKey])} + {label} ), ); diff --git a/packages/origin/src/components/Chart/Chart.unit.test.ts b/packages/origin/src/components/Chart/Chart.unit.test.ts index cbb363665..8037e5c68 100644 --- a/packages/origin/src/components/Chart/Chart.unit.test.ts +++ b/packages/origin/src/components/Chart/Chart.unit.test.ts @@ -21,6 +21,9 @@ import { thinIndices, measureLabelWidth, dynamicTickTarget, + xAxisTickTarget, + omitEdgeLabels, + applyEdgeLabels, axisPadForLabels, formatChartDatumValue, type Point, @@ -736,6 +739,60 @@ describe("dynamicTickTarget", () => { }); }); +// --------------------------------------------------------------------------- +// xAxisTickTarget +// --------------------------------------------------------------------------- + +describe("xAxisTickTarget", () => { + it("uses fixed 60px spacing in 'fixed' mode, ignoring label width", () => { + expect(xAxisTickTarget("fixed", 300, () => ["$1,234,567.00"])).toBe(5); + expect(xAxisTickTarget("fixed", 600, () => ["0"])).toBe(10); + }); + + it("does not evaluate the sample texts in 'fixed' mode", () => { + let called = false; + xAxisTickTarget("fixed", 400, () => { + called = true; + return ["whatever"]; + }); + expect(called).toBe(false); + }); + + it("measures the sample texts in 'measured' mode", () => { + const shortLabels = xAxisTickTarget("measured", 400, () => ["0", "100"]); + const longLabels = xAxisTickTarget("measured", 400, () => [ + "$1,234,567.00", + ]); + expect(shortLabels).toBeGreaterThan(longLabels); + }); +}); + +// --------------------------------------------------------------------------- +// omitEdgeLabels / applyEdgeLabels +// --------------------------------------------------------------------------- + +describe("omitEdgeLabels", () => { + it("drops the first and last entry when there are more than two", () => { + expect(omitEdgeLabels([0, 1, 2, 3])).toEqual([1, 2]); + }); + + it("keeps the list unchanged at two or fewer entries", () => { + expect(omitEdgeLabels([0, 1])).toEqual([0, 1]); + expect(omitEdgeLabels([0])).toEqual([0]); + expect(omitEdgeLabels([])).toEqual([]); + }); +}); + +describe("applyEdgeLabels", () => { + it("drops the edge entries in 'hide' mode", () => { + expect(applyEdgeLabels("hide", [0, 1, 2, 3])).toEqual([1, 2]); + }); + + it("returns the list unchanged in 'show' mode", () => { + expect(applyEdgeLabels("show", [0, 1, 2, 3])).toEqual([0, 1, 2, 3]); + }); +}); + // --------------------------------------------------------------------------- // axisPadForLabels // --------------------------------------------------------------------------- diff --git a/packages/origin/src/components/Chart/ComposedChart.tsx b/packages/origin/src/components/Chart/ComposedChart.tsx index f8805ef5d..77677be77 100644 --- a/packages/origin/src/components/Chart/ComposedChart.tsx +++ b/packages/origin/src/components/Chart/ComposedChart.tsx @@ -12,6 +12,8 @@ import { monotoneInterpolator, linearInterpolator, thinIndices, + xAxisTickTarget, + applyEdgeLabels, axisPadForLabels, formatChartDatumValue, type Point, @@ -36,6 +38,7 @@ import { BAR_ITEM_GAP, resolveTooltipMode, axisTickTarget, + type XAxisLabelProps, } from "./types"; import { ChartWrapper } from "./ChartWrapper"; import styles from "./Chart.module.scss"; @@ -57,7 +60,8 @@ type ResolvedComposedSeries = { }; export interface ComposedChartProps - extends React.ComponentPropsWithoutRef<"div"> { + extends React.ComponentPropsWithoutRef<"div">, + XAxisLabelProps { data: ChartDatum[]; /** * Pre-measurement width in pixels. Used as a fallback before @@ -131,6 +135,8 @@ export const Composed = React.forwardRef( formatValue, formatXLabel, formatYLabel, + xAxisLabels = "fixed", + xAxisEdgeLabels = "show", formatYLabelRight, connectNulls = true, yDomain: yDomainProp, @@ -373,6 +379,30 @@ export const Composed = React.forwardRef( trackedClick(scrub.activeIndex, data[scrub.activeIndex]); }, [onClickDatum, trackedClick, scrub.activeIndex, data]); + // X axis labels (thinned to avoid overlap) + const xLabels = React.useMemo(() => { + if (!xKey) return []; + const labels = data.map((d) => + formatXLabel ? formatXLabel(d[xKey]) : formatChartDatumValue(d[xKey]), + ); + const maxLabels = xAxisTickTarget(xAxisLabels, plotWidth, () => labels); + const indices = thinIndices(data.length, maxLabels); + const visibleIndices = applyEdgeLabels(xAxisEdgeLabels, indices); + return visibleIndices.map((i) => ({ + x: (i + 0.5) * slotWidth, + text: labels[i], + index: i, + })); + }, [ + xKey, + data, + formatXLabel, + xAxisLabels, + xAxisEdgeLabels, + plotWidth, + slotWidth, + ]); + const svgDesc = React.useMemo(() => { if (series.length === 0 || data.length === 0) return undefined; const names = series.map((s) => s.label).join(", "); @@ -689,25 +719,18 @@ export const Composed = React.forwardRef( ))} {/* X axis labels (thinned to avoid overlap) */} - {xKey && - (() => { - const maxLabels = Math.max(2, Math.floor(plotWidth / 60)); - const indices = thinIndices(data.length, maxLabels); - return indices.map((i) => ( - - {formatXLabel - ? formatXLabel(data[i][xKey]) - : formatChartDatumValue(data[i][xKey])} - - )); - })()} + {xLabels.map(({ x, text, index }) => ( + + {text} + + ))} diff --git a/packages/origin/src/components/Chart/LineChart.tsx b/packages/origin/src/components/Chart/LineChart.tsx index 6902d5c50..c09c40c29 100644 --- a/packages/origin/src/components/Chart/LineChart.tsx +++ b/packages/origin/src/components/Chart/LineChart.tsx @@ -12,6 +12,8 @@ import { monotoneInterpolator, linearInterpolator, thinIndices, + xAxisTickTarget, + applyEdgeLabels, axisPadForLabels, formatChartDatumValue, type Point, @@ -34,6 +36,7 @@ import { resolveTooltipMode, resolveSeries, axisTickTarget, + type XAxisLabelProps, } from "./types"; import { ChartWrapper } from "./ChartWrapper"; import styles from "./Chart.module.scss"; @@ -42,7 +45,9 @@ export type { Series, TooltipProp, ReferenceLine, ReferenceBand }; const clickIndexMeta = (index: number) => ({ index }); -export interface LineChartProps extends React.ComponentPropsWithoutRef<"div"> { +export interface LineChartProps + extends React.ComponentPropsWithoutRef<"div">, + XAxisLabelProps { /** * Array of data objects. Each object should contain keys matching `dataKey` or `series[].key`. */ @@ -154,6 +159,8 @@ export const Line = React.forwardRef( formatValue, formatXLabel, formatYLabel, + xAxisLabels = "fixed", + xAxisEdgeLabels = "show", connectNulls = true, initialWidth, className, @@ -394,20 +401,22 @@ export const Line = React.forwardRef( // X axis labels const xLabels = React.useMemo(() => { if (!xKey || data.length === 0 || plotWidth <= 0) return []; - const maxLabels = Math.max(2, Math.floor(plotWidth / 60)); + const labels = data.map((d) => { + const raw = d[xKey]; + return formatXLabel ? formatXLabel(raw) : formatChartDatumValue(raw); + }); + const maxLabels = xAxisTickTarget(xAxisLabels, plotWidth, () => labels); const indices = thinIndices(data.length, maxLabels); - return indices.map((i) => { + const xLabels = indices.map((i) => { const x = data.length === 1 ? plotWidth / 2 : (i / (data.length - 1)) * plotWidth; - const raw = data[i][xKey]; - const text = formatXLabel - ? formatXLabel(raw) - : formatChartDatumValue(raw); + const text = labels[i]; return { x, text, index: i }; }); - }, [xKey, data, plotWidth, formatXLabel]); + return applyEdgeLabels(xAxisEdgeLabels, xLabels); + }, [xKey, data, plotWidth, formatXLabel, xAxisLabels, xAxisEdgeLabels]); // Y axis labels const yLabels = React.useMemo(() => { @@ -899,9 +908,9 @@ export const Line = React.forwardRef( y={plotHeight + 20} className={styles.axisLabel} textAnchor={ - i === 0 + labelIndex === 0 ? "start" - : i === xLabels.length - 1 + : labelIndex === data.length - 1 ? "end" : "middle" } diff --git a/packages/origin/src/components/Chart/ScatterChart.tsx b/packages/origin/src/components/Chart/ScatterChart.tsx index 97c2b5a18..2d980f0fa 100644 --- a/packages/origin/src/components/Chart/ScatterChart.tsx +++ b/packages/origin/src/components/Chart/ScatterChart.tsx @@ -2,7 +2,14 @@ import * as React from "react"; import clsx from "clsx"; -import { linearScale, niceTicks, thinIndices, axisPadForLabels } from "./utils"; +import { + linearScale, + niceTicks, + thinIndices, + xAxisTickTarget, + applyEdgeLabels, + axisPadForLabels, +} from "./utils"; import { useTrackedCallback } from "../Analytics/useTrackedCallback"; import { useResizeWidth } from "./hooks"; import { useMergedRef } from "./useMergedRef"; @@ -17,6 +24,7 @@ import { TOOLTIP_GAP, resolveTooltipMode, axisTickTarget, + type XAxisLabelProps, } from "./types"; import { ChartWrapper } from "./ChartWrapper"; import styles from "./Chart.module.scss"; @@ -37,7 +45,8 @@ export interface ScatterSeries { } export interface ScatterChartProps - extends React.ComponentPropsWithoutRef<"div"> { + extends React.ComponentPropsWithoutRef<"div">, + XAxisLabelProps { data: ScatterSeries[]; /** * Pre-measurement width in pixels. Used as a fallback before @@ -112,6 +121,8 @@ export const Scatter = React.forwardRef( formatValue, formatXLabel, formatYLabel, + xAxisLabels = "fixed", + xAxisEdgeLabels = "show", xDomain: xDomainProp, yDomain: yDomainProp, onClickDatum, @@ -239,13 +250,25 @@ export const Scatter = React.forwardRef( const xLabels = React.useMemo(() => { if (plotWidth <= 0) return []; - const maxLabels = Math.max(2, Math.floor(plotWidth / 60)); + const labels = xTicks.map((tick) => + formatXLabel ? formatXLabel(tick) : String(tick), + ); + const maxLabels = xAxisTickTarget(xAxisLabels, plotWidth, () => labels); const indices = thinIndices(xTicks.length, maxLabels); - return indices.map((i) => ({ + const visibleIndices = applyEdgeLabels(xAxisEdgeLabels, indices); + return visibleIndices.map((i) => ({ x: linearScale(xTicks[i], xMin, xMax, 0, plotWidth), - text: formatXLabel ? formatXLabel(xTicks[i]) : String(xTicks[i]), + text: labels[i], })); - }, [xTicks, xMin, xMax, plotWidth, formatXLabel]); + }, [ + xTicks, + xMin, + xMax, + plotWidth, + formatXLabel, + xAxisLabels, + xAxisEdgeLabels, + ]); const screenPoints = React.useMemo(() => { if (plotWidth <= 0 || plotHeight <= 0) return []; diff --git a/packages/origin/src/components/Chart/StackedAreaChart.tsx b/packages/origin/src/components/Chart/StackedAreaChart.tsx index 09e7f6d50..bb2e90d8a 100644 --- a/packages/origin/src/components/Chart/StackedAreaChart.tsx +++ b/packages/origin/src/components/Chart/StackedAreaChart.tsx @@ -11,6 +11,8 @@ import { linearInterpolator, stackData, thinIndices, + xAxisTickTarget, + applyEdgeLabels, axisPadForLabels, formatChartDatumValue, type Point, @@ -32,6 +34,7 @@ import { resolveTooltipMode, resolveSeries, axisTickTarget, + type XAxisLabelProps, } from "./types"; import { ChartWrapper } from "./ChartWrapper"; import styles from "./Chart.module.scss"; @@ -39,7 +42,8 @@ import styles from "./Chart.module.scss"; const clickIndexMeta = (index: number) => ({ index }); export interface StackedAreaChartProps - extends React.ComponentPropsWithoutRef<"div"> { + extends React.ComponentPropsWithoutRef<"div">, + XAxisLabelProps { data: ChartDatum[]; /** * Pre-measurement width in pixels. Used as a fallback before @@ -107,6 +111,8 @@ export const StackedArea = React.forwardRef< formatValue, formatXLabel, formatYLabel, + xAxisLabels = "fixed", + xAxisEdgeLabels = "show", initialWidth, className, ...props @@ -264,18 +270,20 @@ export const StackedArea = React.forwardRef< // X axis labels const xLabels = React.useMemo(() => { if (!xKey || data.length === 0 || plotWidth <= 0) return []; - const maxLabels = Math.max(2, Math.floor(plotWidth / 60)); + const labels = data.map((d) => { + const raw = d[xKey]; + return formatXLabel ? formatXLabel(raw) : formatChartDatumValue(raw); + }); + const maxLabels = xAxisTickTarget(xAxisLabels, plotWidth, () => labels); const indices = thinIndices(data.length, maxLabels); - return indices.map((i) => { + const xLabels = indices.map((i) => { const x = data.length === 1 ? plotWidth / 2 : (i / (data.length - 1)) * plotWidth; - const raw = data[i][xKey]; - const text = formatXLabel - ? formatXLabel(raw) - : formatChartDatumValue(raw); + const text = labels[i]; return { x, text, index: i }; }); - }, [xKey, data, plotWidth, formatXLabel]); + return applyEdgeLabels(xAxisEdgeLabels, xLabels); + }, [xKey, data, plotWidth, formatXLabel, xAxisLabels, xAxisEdgeLabels]); // Y axis labels const yLabels = React.useMemo(() => { @@ -558,9 +566,9 @@ export const StackedArea = React.forwardRef< y={plotHeight + 20} className={styles.axisLabel} textAnchor={ - i === 0 + labelIndex === 0 ? "start" - : i === xLabels.length - 1 + : labelIndex === data.length - 1 ? "end" : "middle" } diff --git a/packages/origin/src/components/Chart/WaterfallChart.tsx b/packages/origin/src/components/Chart/WaterfallChart.tsx index 13a3dba54..ac4100aa5 100644 --- a/packages/origin/src/components/Chart/WaterfallChart.tsx +++ b/packages/origin/src/components/Chart/WaterfallChart.tsx @@ -2,7 +2,14 @@ import * as React from "react"; import clsx from "clsx"; -import { linearScale, niceTicks, thinIndices, axisPadForLabels } from "./utils"; +import { + linearScale, + niceTicks, + thinIndices, + xAxisTickTarget, + applyEdgeLabels, + axisPadForLabels, +} from "./utils"; import { useResizeWidth } from "./hooks"; import { useMergedRef } from "./useMergedRef"; import { @@ -12,6 +19,7 @@ import { PAD_BOTTOM_AXIS, TOOLTIP_GAP, axisTickTarget, + type XAxisLabelProps, } from "./types"; import { ChartWrapper } from "./ChartWrapper"; import { useTrackedCallback } from "../Analytics/useTrackedCallback"; @@ -26,7 +34,8 @@ export interface WaterfallSegment { } export interface WaterfallChartProps - extends React.ComponentPropsWithoutRef<"div"> { + extends React.ComponentPropsWithoutRef<"div">, + XAxisLabelProps { data: WaterfallSegment[]; /** * Pre-measurement width in pixels. Used as a fallback before @@ -78,6 +87,8 @@ export const Waterfall = React.forwardRef( data, formatValue, formatYLabel, + xAxisLabels = "fixed", + xAxisEdgeLabels = "show", showConnectors = true, showValues = false, height = 300, @@ -317,9 +328,12 @@ export const Waterfall = React.forwardRef( }, [data.length]); const xLabelIndices = React.useMemo(() => { - const maxLabels = Math.max(2, Math.floor(plotWidth / 60)); - return thinIndices(data.length, maxLabels); - }, [data.length, plotWidth]); + const maxLabels = xAxisTickTarget(xAxisLabels, plotWidth, () => + data.map((d) => d.label), + ); + const indices = thinIndices(data.length, maxLabels); + return applyEdgeLabels(xAxisEdgeLabels, indices); + }, [data, plotWidth, xAxisLabels, xAxisEdgeLabels]); return ( = { solid: undefined, dashed: "4 4", diff --git a/packages/origin/src/components/Chart/utils.ts b/packages/origin/src/components/Chart/utils.ts index 3e659285c..bb7ad81cb 100644 --- a/packages/origin/src/components/Chart/utils.ts +++ b/packages/origin/src/components/Chart/utils.ts @@ -1,4 +1,9 @@ -import type { ChartDatum } from "./types"; +import { + axisTickTarget, + type ChartDatum, + type XAxisLabelsMode, + type XAxisEdgeLabelsMode, +} from "./types"; export const CHART_LABEL_FONT = '11px "Suisse Intl Mono", "SF Mono", Menlo, monospace'; @@ -29,11 +34,39 @@ export function dynamicTickTarget( axisLength: number, sampleTexts: string[], ): number { - if (sampleTexts.length === 0) return Math.max(2, Math.floor(axisLength / 60)); + if (sampleTexts.length === 0) return axisTickTarget(axisLength, true); const maxWidth = Math.max(...sampleTexts.map(measureLabelWidth)); return Math.max(2, Math.floor(axisLength / (maxWidth + LABEL_PADDING))); } +/** + * Pick the x-axis tick target for the given labels-thinning mode. `sampleTexts` + * is only evaluated in `"measured"` mode, so the default `"fixed"` path never + * builds or measures the label strings. + */ +export function xAxisTickTarget( + mode: XAxisLabelsMode, + axisLength: number, + sampleTexts: () => string[], +): number { + return mode === "measured" + ? dynamicTickTarget(axisLength, sampleTexts()) + : axisTickTarget(axisLength, true); +} + +/** Drop the first and last entry of an already-thinned label list (>2 items). */ +export function omitEdgeLabels(labels: T[]): T[] { + return labels.length > 2 ? labels.slice(1, -1) : labels; +} + +/** Apply the edge-labels mode to an already-thinned label list. */ +export function applyEdgeLabels( + mode: XAxisEdgeLabelsMode, + labels: T[], +): T[] { + return mode === "hide" ? omitEdgeLabels(labels) : labels; +} + /** Minimum left padding so very short labels (e.g. "0") don't crowd the axis. */ const MIN_AXIS_PAD = 24; /** Gap between label right edge and plot area left edge. */ From f7e42c2e8c69f725bb488b7b1848c48cff8a7802 Mon Sep 17 00:00:00 2001 From: Jay Mantri Date: Tue, 9 Jun 2026 15:48:47 -0700 Subject: [PATCH 66/90] [origin] Fix long-list dropdown scrolling (#28492) ## Reason [DES-58](https://lightspark.atlassian.net/browse/DES-58) exposed that long PhoneInput country menus could be clipped because popup chrome and list scrolling were owned by the same element. This moves scroll ownership to the list for the ungrouped popup components that need bounded long-list behavior, while keeping the popup responsible for border, radius, shadow, and overflow clipping. ## Overview - Keep PhoneInput, Combobox, and Autocomplete popup chrome clipped while their listbox content owns max-height, padding, overscroll behavior, and vertical scrolling. - Add long-list stories and component tests for the affected components so the scroll boundary stays explicit. - Intentionally leave Select unchanged because grouped Select content still relies on popup-level scrolling. ## Storybook preview - Components/PhoneInput: LongCountryList: https://dev.dev.sparkinfra.net/app/origin-storybook-pr-28492/?path=/story/components-phoneinput--long-country-list - Components/Combobox: LongList: https://dev.dev.sparkinfra.net/app/origin-storybook-pr-28492/?path=/story/components-combobox--long-list - Components/Autocomplete: LongList: https://dev.dev.sparkinfra.net/app/origin-storybook-pr-28492/?path=/story/components-autocomplete--long-list ## Test Plan - yarn workspace @lightsparkdev/origin lint - yarn workspace @lightsparkdev/origin types - yarn workspace @lightsparkdev/origin test:ct src/components/Autocomplete/Autocomplete.test.tsx src/components/Combobox/Combobox.test.tsx src/components/PhoneInput/PhoneInput.test.tsx [DES-58]: https://lightspark.atlassian.net/browse/DES-58?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Cursor GitOrigin-RevId: f64eb0f37b312e55c51114f9f5638085870d2d05 --- .../Autocomplete/Autocomplete.module.scss | 2 +- .../Autocomplete/Autocomplete.stories.tsx | 32 +++++++ .../Autocomplete.test-stories.tsx | 33 +++++++ .../Autocomplete/Autocomplete.test.tsx | 89 +++++++++++++++++++ .../components/Combobox/Combobox.module.scss | 2 +- .../components/Combobox/Combobox.stories.tsx | 33 +++++++ .../Combobox/Combobox.test-stories.tsx | 31 +++++++ .../src/components/Combobox/Combobox.test.tsx | 70 +++++++++++++++ .../PhoneInput/PhoneInput.module.scss | 3 +- .../PhoneInput/PhoneInput.stories.tsx | 64 ++++++++++++- .../PhoneInput/PhoneInput.test-stories.tsx | 65 +++++++++++++- .../components/PhoneInput/PhoneInput.test.tsx | 69 ++++++++++++++ 12 files changed, 484 insertions(+), 9 deletions(-) diff --git a/packages/origin/src/components/Autocomplete/Autocomplete.module.scss b/packages/origin/src/components/Autocomplete/Autocomplete.module.scss index 76e6d42d8..22f7c669c 100644 --- a/packages/origin/src/components/Autocomplete/Autocomplete.module.scss +++ b/packages/origin/src/components/Autocomplete/Autocomplete.module.scss @@ -49,7 +49,6 @@ .popup { box-sizing: border-box; width: var(--anchor-width); - max-height: min(23rem, var(--available-height)); max-width: var(--available-width); overflow: hidden; background: var(--surface-primary); @@ -95,6 +94,7 @@ display: flex; gap: var(--spacing-2xs); align-items: center; + flex-shrink: 0; height: 36px; padding: var(--spacing-xs); @include smooth-corners(var(--corner-radius-xs)); diff --git a/packages/origin/src/components/Autocomplete/Autocomplete.stories.tsx b/packages/origin/src/components/Autocomplete/Autocomplete.stories.tsx index fa5042b9c..fe548435f 100644 --- a/packages/origin/src/components/Autocomplete/Autocomplete.stories.tsx +++ b/packages/origin/src/components/Autocomplete/Autocomplete.stories.tsx @@ -21,6 +21,11 @@ const fruits: Fruit[] = [ { value: "honeydew", label: "Honeydew" }, ]; +const longFruits: Fruit[] = Array.from({ length: 40 }, (_, index) => ({ + value: `fruit-${index + 1}`, + label: `Fruit ${index + 1}`, +})); + const meta: Meta = { title: "Components/Autocomplete", component: Autocomplete.Root, @@ -62,6 +67,33 @@ export const Basic: Story = { ), }; +export const LongList: Story = { + render: () => ( +
+ item.label} + > + + + + + No results found. + + {(item: Fruit) => ( + + {item.label} + + )} + + + + + +
+ ), +}; + export const WithLeadingIcons: Story = { render: () => (
diff --git a/packages/origin/src/components/Autocomplete/Autocomplete.test-stories.tsx b/packages/origin/src/components/Autocomplete/Autocomplete.test-stories.tsx index bf93332e3..144425217 100644 --- a/packages/origin/src/components/Autocomplete/Autocomplete.test-stories.tsx +++ b/packages/origin/src/components/Autocomplete/Autocomplete.test-stories.tsx @@ -19,6 +19,11 @@ const fruits: Fruit[] = [ { value: "elderberry", label: "Elderberry" }, ]; +const longFruits: Fruit[] = Array.from({ length: 40 }, (_, index) => ({ + value: `fruit-${index + 1}`, + label: `Fruit ${index + 1}`, +})); + const groupedItems = [ { label: "Fruits", @@ -61,6 +66,34 @@ export function BasicAutocomplete() { ); } +/** + * Autocomplete with enough items to require list scrolling. + */ +export function LongListAutocomplete() { + return ( + item.label} + > + + + + + No results found. + + {(item: Fruit) => ( + + {item.label} + + )} + + + + + + ); +} + /** * Autocomplete with leading icons */ diff --git a/packages/origin/src/components/Autocomplete/Autocomplete.test.tsx b/packages/origin/src/components/Autocomplete/Autocomplete.test.tsx index d5446208f..d4053943e 100644 --- a/packages/origin/src/components/Autocomplete/Autocomplete.test.tsx +++ b/packages/origin/src/components/Autocomplete/Autocomplete.test.tsx @@ -1,6 +1,7 @@ import { test, expect } from "@playwright/experimental-ct-react"; import { BasicAutocomplete, + LongListAutocomplete, WithLeadingIcon, WithDisabledItems, DisabledAutocomplete, @@ -32,6 +33,94 @@ test.describe("Autocomplete", () => { await expect(page.getByRole("listbox")).toBeVisible(); }); + test("keeps long-list scrolling on the list", async ({ mount, page }) => { + const component = await mount(); + const input = component.getByPlaceholder("Search fruits..."); + + await input.focus(); + await input.press("ArrowDown"); + + const popup = page.getByTestId("autocomplete-long-list-popup"); + const listbox = page.getByTestId("autocomplete-long-list"); + await expect(listbox).toBeVisible(); + + const state = await listbox.evaluate((list) => { + const popup = document.querySelector( + '[data-testid="autocomplete-long-list-popup"]', + ); + const firstItem = list.querySelector('[role="option"]'); + + if (!(popup instanceof HTMLElement)) { + throw new Error("Autocomplete long-list popup is missing"); + } + + if (!(firstItem instanceof HTMLElement)) { + throw new Error("Autocomplete list is missing option rows"); + } + + const listStyles = window.getComputedStyle(list); + const popupStyles = window.getComputedStyle(popup); + const itemStyles = window.getComputedStyle(firstItem); + popup.scrollTop = popup.scrollHeight; + list.scrollTop = list.scrollHeight; + + return { + itemFlexShrink: itemStyles.flexShrink, + itemHeight: itemStyles.height, + itemRenderedHeight: firstItem.getBoundingClientRect().height, + popupMaxHeight: popupStyles.maxHeight, + popupOverflowY: popupStyles.overflowY, + popupHasScrollableOverflow: popup.scrollHeight > popup.clientHeight, + popupCanScroll: popup.scrollTop > 0, + listMaxHeight: listStyles.maxHeight, + listOverflowY: listStyles.overflowY, + listOverscrollBehaviorY: listStyles.overscrollBehaviorY, + listScrollPaddingBlockEnd: listStyles.scrollPaddingBlockEnd, + listScrollPaddingBlockStart: listStyles.scrollPaddingBlockStart, + listHasScrollableOverflow: list.scrollHeight > list.clientHeight, + listCanScroll: list.scrollTop > 0, + }; + }); + + expect(state.itemFlexShrink).toBe("0"); + expect(state.itemHeight).toBe("36px"); + expect(state.itemRenderedHeight).toBeGreaterThanOrEqual(34); + expect(state.popupMaxHeight).toBe("none"); + expect(state.popupOverflowY).toBe("hidden"); + expect(state.popupHasScrollableOverflow).toBe(false); + expect(state.popupCanScroll).toBe(false); + expect(state.listMaxHeight).not.toBe("none"); + expect(state.listOverflowY).toBe("auto"); + expect(state.listOverscrollBehaviorY).toBe("contain"); + expect( + Number.parseFloat(state.listScrollPaddingBlockStart), + ).toBeGreaterThan(0); + expect( + Number.parseFloat(state.listScrollPaddingBlockEnd), + ).toBeGreaterThan(0); + expect(state.listHasScrollableOverflow).toBe(true); + expect(state.listCanScroll).toBe(true); + + await expect(popup).toBeVisible(); + await expect( + page.getByRole("option", { name: "Fruit 40" }), + ).toBeVisible(); + }); + + test("filters long-list object items by label", async ({ mount, page }) => { + const component = await mount(); + const input = component.getByPlaceholder("Search fruits..."); + + await input.fill("40"); + + await expect( + page.getByRole("option", { name: "Fruit 40" }), + ).toBeVisible(); + await expect( + page.getByRole("option", { name: "Fruit 1" }), + ).not.toBeVisible(); + }); + test("filters items as user types", async ({ mount, page }) => { const component = await mount(); const input = component.getByPlaceholder("Search fruits..."); diff --git a/packages/origin/src/components/Combobox/Combobox.module.scss b/packages/origin/src/components/Combobox/Combobox.module.scss index 065f5a402..2426280d3 100644 --- a/packages/origin/src/components/Combobox/Combobox.module.scss +++ b/packages/origin/src/components/Combobox/Combobox.module.scss @@ -170,7 +170,6 @@ .popup { box-sizing: border-box; width: var(--anchor-width); - max-height: min(23rem, var(--available-height)); max-width: var(--available-width); overflow: hidden; background: var(--surface-primary); @@ -216,6 +215,7 @@ display: flex; gap: var(--spacing-2xs); align-items: center; + flex-shrink: 0; height: 36px; padding: var(--spacing-xs); @include smooth-corners(var(--corner-radius-xs)); diff --git a/packages/origin/src/components/Combobox/Combobox.stories.tsx b/packages/origin/src/components/Combobox/Combobox.stories.tsx index 3c5d69729..7e67d27a3 100644 --- a/packages/origin/src/components/Combobox/Combobox.stories.tsx +++ b/packages/origin/src/components/Combobox/Combobox.stories.tsx @@ -27,6 +27,11 @@ const fruits = [ "Lemon", ]; +const longFruits = Array.from( + { length: 40 }, + (_, index) => `Fruit ${index + 1}`, +); + export const Default: Story = { args: { disabled: false, @@ -58,6 +63,34 @@ export const Default: Story = { ), }; +export const LongList: Story = { + render: () => ( + + + + + + + + + + + + + {(item: string) => ( + + + {item} + + )} + + + + + + ), +}; + export const WithClear: Story = { render: () => ( diff --git a/packages/origin/src/components/Combobox/Combobox.test-stories.tsx b/packages/origin/src/components/Combobox/Combobox.test-stories.tsx index e2ee1ae71..aeddfc107 100644 --- a/packages/origin/src/components/Combobox/Combobox.test-stories.tsx +++ b/packages/origin/src/components/Combobox/Combobox.test-stories.tsx @@ -12,6 +12,11 @@ const fruits = [ "Grape", ]; +const longFruits = Array.from( + { length: 40 }, + (_, index) => `Fruit ${index + 1}`, +); + /** InputWrapper conformance - forwards props, ref, className */ export function ConformanceInputWrapper( props: React.HTMLAttributes, @@ -99,6 +104,32 @@ export const TestCombobox = () => ( ); +export const TestComboboxLongList = () => ( + + + + + + + + + + + + + {(item: string) => ( + + + {item} + + )} + + + + + +); + export const TestComboboxMultiple = () => ( diff --git a/packages/origin/src/components/Combobox/Combobox.test.tsx b/packages/origin/src/components/Combobox/Combobox.test.tsx index 961561a50..f114add4f 100644 --- a/packages/origin/src/components/Combobox/Combobox.test.tsx +++ b/packages/origin/src/components/Combobox/Combobox.test.tsx @@ -1,6 +1,7 @@ import { test, expect } from "@playwright/experimental-ct-react"; import { TestCombobox, + TestComboboxLongList, TestComboboxMultiple, TestComboboxDisabled, TestComboboxDefaultValue, @@ -57,6 +58,75 @@ test.describe("Combobox", () => { await expect(page.getByRole("option", { name: "Grape" })).toBeVisible(); }); + test("keeps long-list scrolling on the list", async ({ mount, page }) => { + const component = await mount(); + const input = component.getByPlaceholder("Select a fruit..."); + + await input.click(); + + const popup = page.getByTestId("combobox-long-list-popup"); + const listbox = page.getByTestId("combobox-long-list"); + await expect(listbox).toBeVisible(); + + const state = await listbox.evaluate((list) => { + const popup = document.querySelector( + '[data-testid="combobox-long-list-popup"]', + ); + const firstItem = list.querySelector('[role="option"]'); + + if (!(popup instanceof HTMLElement)) { + throw new Error("Combobox long-list popup is missing"); + } + + if (!(firstItem instanceof HTMLElement)) { + throw new Error("Combobox list is missing option rows"); + } + + const listStyles = window.getComputedStyle(list); + const popupStyles = window.getComputedStyle(popup); + const itemStyles = window.getComputedStyle(firstItem); + popup.scrollTop = popup.scrollHeight; + list.scrollTop = list.scrollHeight; + + return { + itemFlexShrink: itemStyles.flexShrink, + popupMaxHeight: popupStyles.maxHeight, + popupOverflowY: popupStyles.overflowY, + popupHasScrollableOverflow: popup.scrollHeight > popup.clientHeight, + popupCanScroll: popup.scrollTop > 0, + listMaxHeight: listStyles.maxHeight, + listOverflowY: listStyles.overflowY, + listOverscrollBehaviorY: listStyles.overscrollBehaviorY, + listScrollPaddingBlockEnd: listStyles.scrollPaddingBlockEnd, + listScrollPaddingBlockStart: listStyles.scrollPaddingBlockStart, + listHasScrollableOverflow: list.scrollHeight > list.clientHeight, + listCanScroll: list.scrollTop > 0, + }; + }); + + expect(state.itemFlexShrink).toBe("0"); + expect(state.popupMaxHeight).toBe("none"); + expect(state.popupOverflowY).toBe("hidden"); + expect(state.popupHasScrollableOverflow).toBe(false); + expect(state.popupCanScroll).toBe(false); + expect(state.listMaxHeight).not.toBe("none"); + expect(state.listOverflowY).toBe("auto"); + expect(state.listOverscrollBehaviorY).toBe("contain"); + expect( + Number.parseFloat(state.listScrollPaddingBlockStart), + ).toBeGreaterThan(0); + expect( + Number.parseFloat(state.listScrollPaddingBlockEnd), + ).toBeGreaterThan(0); + expect(state.listHasScrollableOverflow).toBe(true); + expect(state.listCanScroll).toBe(true); + + await expect(popup).toBeVisible(); + await expect( + page.getByRole("option", { name: "Fruit 40" }), + ).toBeVisible(); + }); + test("shows empty state when no matches", async ({ mount, page }) => { const component = await mount(); const input = component.getByPlaceholder("Select a fruit..."); diff --git a/packages/origin/src/components/PhoneInput/PhoneInput.module.scss b/packages/origin/src/components/PhoneInput/PhoneInput.module.scss index f85577bc5..090898333 100644 --- a/packages/origin/src/components/PhoneInput/PhoneInput.module.scss +++ b/packages/origin/src/components/PhoneInput/PhoneInput.module.scss @@ -135,7 +135,6 @@ .popup { box-sizing: border-box; width: var(--anchor-width); - max-height: min(23rem, var(--available-height)); overflow: hidden; background: var(--surface-primary); border: var(--stroke-xs) solid var(--border-primary); @@ -163,6 +162,7 @@ display: flex; flex-direction: column; gap: var(--spacing-4xs); + max-height: min(23rem, var(--available-height)); padding: var(--spacing-3xs); overflow-y: auto; overscroll-behavior: contain; @@ -175,6 +175,7 @@ display: flex; gap: var(--spacing-xs); align-items: center; + flex-shrink: 0; height: 36px; padding: var(--spacing-xs); @include smooth-corners(var(--corner-radius-xs)); diff --git a/packages/origin/src/components/PhoneInput/PhoneInput.stories.tsx b/packages/origin/src/components/PhoneInput/PhoneInput.stories.tsx index 9031cf198..cdbdef6d2 100644 --- a/packages/origin/src/components/PhoneInput/PhoneInput.stories.tsx +++ b/packages/origin/src/components/PhoneInput/PhoneInput.stories.tsx @@ -5,7 +5,13 @@ import type { Meta, StoryObj } from "@storybook/react"; import { PhoneInput } from "./"; import { Field } from "@/components/Field"; -const exampleCountries = [ +interface Country { + code: string; + name: string; + dialCode: string; +} + +const exampleCountries: Country[] = [ { code: "US", name: "United States", dialCode: "+1" }, { code: "GB", name: "United Kingdom", dialCode: "+44" }, { code: "DE", name: "Germany", dialCode: "+49" }, @@ -18,7 +24,48 @@ const exampleCountries = [ { code: "MX", name: "Mexico", dialCode: "+52" }, ]; -type Country = (typeof exampleCountries)[number]; +const longExampleCountries: Country[] = [ + { code: "US", name: "United States", dialCode: "+1" }, + { code: "CA", name: "Canada", dialCode: "+1" }, + { code: "MX", name: "Mexico", dialCode: "+52" }, + { code: "BR", name: "Brazil", dialCode: "+55" }, + { code: "AR", name: "Argentina", dialCode: "+54" }, + { code: "GB", name: "United Kingdom", dialCode: "+44" }, + { code: "IE", name: "Ireland", dialCode: "+353" }, + { code: "FR", name: "France", dialCode: "+33" }, + { code: "DE", name: "Germany", dialCode: "+49" }, + { code: "NL", name: "Netherlands", dialCode: "+31" }, + { code: "BE", name: "Belgium", dialCode: "+32" }, + { code: "ES", name: "Spain", dialCode: "+34" }, + { code: "PT", name: "Portugal", dialCode: "+351" }, + { code: "IT", name: "Italy", dialCode: "+39" }, + { code: "CH", name: "Switzerland", dialCode: "+41" }, + { code: "AT", name: "Austria", dialCode: "+43" }, + { code: "SE", name: "Sweden", dialCode: "+46" }, + { code: "NO", name: "Norway", dialCode: "+47" }, + { code: "DK", name: "Denmark", dialCode: "+45" }, + { code: "FI", name: "Finland", dialCode: "+358" }, + { code: "PL", name: "Poland", dialCode: "+48" }, + { code: "CZ", name: "Czechia", dialCode: "+420" }, + { code: "GR", name: "Greece", dialCode: "+30" }, + { code: "TR", name: "Turkey", dialCode: "+90" }, + { code: "IL", name: "Israel", dialCode: "+972" }, + { code: "AE", name: "United Arab Emirates", dialCode: "+971" }, + { code: "IN", name: "India", dialCode: "+91" }, + { code: "SG", name: "Singapore", dialCode: "+65" }, + { code: "JP", name: "Japan", dialCode: "+81" }, + { code: "KR", name: "South Korea", dialCode: "+82" }, + { code: "CN", name: "China", dialCode: "+86" }, + { code: "HK", name: "Hong Kong", dialCode: "+852" }, + { code: "TW", name: "Taiwan", dialCode: "+886" }, + { code: "AU", name: "Australia", dialCode: "+61" }, + { code: "NZ", name: "New Zealand", dialCode: "+64" }, + { code: "ZA", name: "South Africa", dialCode: "+27" }, + { code: "EG", name: "Egypt", dialCode: "+20" }, + { code: "NG", name: "Nigeria", dialCode: "+234" }, + { code: "KE", name: "Kenya", dialCode: "+254" }, + { code: "ZW", name: "Zimbabwe", dialCode: "+263" }, +]; function getFlagUrl(code: string) { return `https://hatscripts.github.io/circle-flags/flags/${code.toLowerCase()}.svg`; @@ -42,10 +89,12 @@ function PhoneInputExample({ disabled = false, placeholder = "Enter phone", defaultCountry = exampleCountries[0], + countries = exampleCountries, }: { disabled?: boolean; placeholder?: string; defaultCountry?: Country; + countries?: Country[]; }) { const [selectedCountry, setSelectedCountry] = React.useState(defaultCountry); @@ -73,7 +122,7 @@ function PhoneInputExample({ - {exampleCountries.map((country) => ( + {countries.map((country) => ( @@ -108,6 +157,15 @@ export const WithDefaultCountry: StoryObj = { render: () => , }; +export const LongCountryList: StoryObj = { + render: () => ( + + ), +}; + // Controlled example with form function ControlledExample() { const [selectedCountry, setSelectedCountry] = React.useState( diff --git a/packages/origin/src/components/PhoneInput/PhoneInput.test-stories.tsx b/packages/origin/src/components/PhoneInput/PhoneInput.test-stories.tsx index 5e0cca3a1..68cd23c9a 100644 --- a/packages/origin/src/components/PhoneInput/PhoneInput.test-stories.tsx +++ b/packages/origin/src/components/PhoneInput/PhoneInput.test-stories.tsx @@ -3,8 +3,14 @@ import * as React from "react"; import { PhoneInput } from "./"; +interface Country { + code: string; + name: string; + dialCode: string; +} + // Mock country data for tests -const mockCountries = [ +const mockCountries: Country[] = [ { code: "US", name: "United States", dialCode: "+1" }, { code: "GB", name: "United Kingdom", dialCode: "+44" }, { code: "DE", name: "Germany", dialCode: "+49" }, @@ -12,7 +18,48 @@ const mockCountries = [ { code: "JP", name: "Japan", dialCode: "+81" }, ]; -type Country = (typeof mockCountries)[number]; +const longCountries: Country[] = [ + { code: "US", name: "United States", dialCode: "+1" }, + { code: "CA", name: "Canada", dialCode: "+1" }, + { code: "MX", name: "Mexico", dialCode: "+52" }, + { code: "BR", name: "Brazil", dialCode: "+55" }, + { code: "AR", name: "Argentina", dialCode: "+54" }, + { code: "GB", name: "United Kingdom", dialCode: "+44" }, + { code: "IE", name: "Ireland", dialCode: "+353" }, + { code: "FR", name: "France", dialCode: "+33" }, + { code: "DE", name: "Germany", dialCode: "+49" }, + { code: "NL", name: "Netherlands", dialCode: "+31" }, + { code: "BE", name: "Belgium", dialCode: "+32" }, + { code: "ES", name: "Spain", dialCode: "+34" }, + { code: "PT", name: "Portugal", dialCode: "+351" }, + { code: "IT", name: "Italy", dialCode: "+39" }, + { code: "CH", name: "Switzerland", dialCode: "+41" }, + { code: "AT", name: "Austria", dialCode: "+43" }, + { code: "SE", name: "Sweden", dialCode: "+46" }, + { code: "NO", name: "Norway", dialCode: "+47" }, + { code: "DK", name: "Denmark", dialCode: "+45" }, + { code: "FI", name: "Finland", dialCode: "+358" }, + { code: "PL", name: "Poland", dialCode: "+48" }, + { code: "CZ", name: "Czechia", dialCode: "+420" }, + { code: "GR", name: "Greece", dialCode: "+30" }, + { code: "TR", name: "Turkey", dialCode: "+90" }, + { code: "IL", name: "Israel", dialCode: "+972" }, + { code: "AE", name: "United Arab Emirates", dialCode: "+971" }, + { code: "IN", name: "India", dialCode: "+91" }, + { code: "SG", name: "Singapore", dialCode: "+65" }, + { code: "JP", name: "Japan", dialCode: "+81" }, + { code: "KR", name: "South Korea", dialCode: "+82" }, + { code: "CN", name: "China", dialCode: "+86" }, + { code: "HK", name: "Hong Kong", dialCode: "+852" }, + { code: "TW", name: "Taiwan", dialCode: "+886" }, + { code: "AU", name: "Australia", dialCode: "+61" }, + { code: "NZ", name: "New Zealand", dialCode: "+64" }, + { code: "ZA", name: "South Africa", dialCode: "+27" }, + { code: "EG", name: "Egypt", dialCode: "+20" }, + { code: "NG", name: "Nigeria", dialCode: "+234" }, + { code: "KE", name: "Kenya", dialCode: "+254" }, + { code: "ZW", name: "Zimbabwe", dialCode: "+263" }, +]; // Circle-flags CDN URL helper function getFlagUrl(code: string) { @@ -24,6 +71,7 @@ interface PhoneInputStoryProps { disabled?: boolean; invalid?: boolean; placeholder?: string; + countries?: Country[]; } function PhoneInputStory({ @@ -31,6 +79,7 @@ function PhoneInputStory({ disabled = false, invalid = false, placeholder = "Enter phone", + countries = mockCountries, }: PhoneInputStoryProps) { const [selectedCountry, setSelectedCountry] = React.useState(defaultCountry); @@ -57,7 +106,7 @@ function PhoneInputStory({ - {mockCountries.map((country) => ( + {countries.map((country) => ( @@ -105,6 +154,16 @@ export function CustomPlaceholder() { return ; } +// Long list matching real country selector density +export function LongCountryList() { + return ( + + ); +} + // Controlled with phone number pre-filled export function WithPhoneNumber() { const [selectedCountry, setSelectedCountry] = React.useState( diff --git a/packages/origin/src/components/PhoneInput/PhoneInput.test.tsx b/packages/origin/src/components/PhoneInput/PhoneInput.test.tsx index 8b0aeede1..e0943b98b 100644 --- a/packages/origin/src/components/PhoneInput/PhoneInput.test.tsx +++ b/packages/origin/src/components/PhoneInput/PhoneInput.test.tsx @@ -6,6 +6,7 @@ import { Disabled, Invalid, CustomPlaceholder, + LongCountryList, WithPhoneNumber, } from "./PhoneInput.test-stories"; @@ -56,6 +57,74 @@ test.describe("PhoneInput", () => { ).toBeVisible(); }); + test("bounds long country lists and lets the list scroll", async ({ + mount, + page, + }) => { + await mount(); + + const trigger = page.getByRole("combobox"); + await trigger.click(); + + const popup = page.locator("[data-phone-input-popup]"); + const listbox = page.getByRole("listbox"); + await expect(listbox).toBeVisible(); + + const listState = await listbox.evaluate((list) => { + const popup = document.querySelector("[data-phone-input-popup]"); + const firstItem = list.querySelector('[role="option"]'); + + if (!(popup instanceof HTMLElement)) { + throw new Error("PhoneInput long-list popup is missing"); + } + + if (!(firstItem instanceof HTMLElement)) { + throw new Error("PhoneInput list is missing option rows"); + } + + const listStyles = window.getComputedStyle(list); + const popupStyles = window.getComputedStyle(popup); + const itemStyles = window.getComputedStyle(firstItem); + popup.scrollTop = popup.scrollHeight; + list.scrollTop = list.scrollHeight; + + return { + itemFlexShrink: itemStyles.flexShrink, + popupMaxHeight: popupStyles.maxHeight, + popupOverflowY: popupStyles.overflowY, + popupHasScrollableOverflow: popup.scrollHeight > popup.clientHeight, + popupCanScroll: popup.scrollTop > 0, + maxHeight: listStyles.maxHeight, + overflowY: listStyles.overflowY, + overscrollBehaviorY: listStyles.overscrollBehaviorY, + scrollPaddingBlockEnd: listStyles.scrollPaddingBlockEnd, + scrollPaddingBlockStart: listStyles.scrollPaddingBlockStart, + hasScrollableOverflow: list.scrollHeight > list.clientHeight, + canScroll: list.scrollTop > 0, + }; + }); + + expect(listState.itemFlexShrink).toBe("0"); + expect(listState.popupMaxHeight).toBe("none"); + expect(listState.popupOverflowY).toBe("hidden"); + expect(listState.popupHasScrollableOverflow).toBe(false); + expect(listState.popupCanScroll).toBe(false); + expect(listState.maxHeight).not.toBe("none"); + expect(listState.overflowY).toBe("auto"); + expect(listState.overscrollBehaviorY).toBe("contain"); + expect( + Number.parseFloat(listState.scrollPaddingBlockStart), + ).toBeGreaterThan(0); + expect(Number.parseFloat(listState.scrollPaddingBlockEnd)).toBeGreaterThan( + 0, + ); + expect(listState.hasScrollableOverflow).toBe(true); + expect(listState.canScroll).toBe(true); + + await expect(popup).toBeVisible(); + await expect(page.getByRole("option", { name: /Zimbabwe/ })).toBeVisible(); + }); + test("can select a different country", async ({ mount, page }) => { await mount(); From 7ad22a74050c898d2b2092b8e346b986695923ca Mon Sep 17 00:00:00 2001 From: Jay Mantri Date: Wed, 10 Jun 2026 17:38:22 -0700 Subject: [PATCH 67/90] [origin] Default combobox clear to active (#28544) ## Reason [DES-36](https://lightspark.atlassian.net/browse/DES-36) needs edit-existing forms to avoid showing a noisy clear affordance for saved country/nationality values until the user is actually interacting with the field. Origin now makes that active behavior the default so consumers get the quieter edit-existing state without product-level props. ## Overview Adds `visibility` to `Combobox.Clear` with `active` and `always` modes while preserving Base UI's ownership of clear behavior and clearable state. `active` is the new default: the clear affordance is hidden at rest and appears while the field is focused or open. This intentionally changes existing `` consumers from "always visible when clearable" to "visible while active when clearable." Consumers that need the previous/create-flow behavior can opt into `visibility="always"`. Consumers that should not expose a clear affordance should omit ``. ## QA Notes Existing Combobox.Clear consumers should be checked with the new default in mind: - Edit-existing surfaces should no longer show the clear icon at rest when they load with saved values. - Focusing/opening the combobox should reveal the clear affordance when Base UI reports a clearable value. - Create/select flows that want the clear icon visible while a value exists should use `visibility="always"`. - Flows that should not offer clearing should omit ``. ## Storybook preview - Components/Combobox / Default: https://dev.dev.sparkinfra.net/app/origin-storybook-pr-28544/?path=/story/components-combobox--default - Components/Combobox / Default Active Clear: https://dev.dev.sparkinfra.net/app/origin-storybook-pr-28544/?path=/story/components-combobox--with-clear - Components/Combobox / Always Clear: https://dev.dev.sparkinfra.net/app/origin-storybook-pr-28544/?path=/story/components-combobox--always-clear ## Test Plan - `mise exec -- node -v` -> `v20.19.6` - `mise exec -- corepack yarn --version` -> `4.13.0` - `mise exec -- corepack yarn workspace @lightsparkdev/origin playwright test -c playwright-ct.config.ts src/components/Combobox/Combobox.test.tsx` - `mise exec -- corepack yarn workspace @lightsparkdev/origin types` - `mise exec -- corepack yarn workspace @lightsparkdev/origin lint` (passes with existing warnings in DatePicker/Sidebar) - `mise exec -- corepack yarn workspace @lightsparkdev/origin format` [DES-36]: https://lightspark.atlassian.net/browse/DES-36?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Cursor GitOrigin-RevId: 9423d49750e801bb7e60f4a26b75457040dd226d --- .../components/Combobox/Combobox.module.scss | 20 +++- .../components/Combobox/Combobox.stories.tsx | 54 +++++++++-- .../Combobox/Combobox.test-stories.tsx | 25 ++++- .../src/components/Combobox/Combobox.test.tsx | 94 ++++++++++++++++++- .../origin/src/components/Combobox/index.ts | 1 + .../origin/src/components/Combobox/parts.tsx | 47 ++++++++-- packages/origin/src/index.ts | 4 + packages/origin/tsconfig.json | 2 +- packages/origin/type-tests/combobox-clear.tsx | 7 ++ 9 files changed, 231 insertions(+), 23 deletions(-) create mode 100644 packages/origin/type-tests/combobox-clear.tsx diff --git a/packages/origin/src/components/Combobox/Combobox.module.scss b/packages/origin/src/components/Combobox/Combobox.module.scss index 2426280d3..c675f6e78 100644 --- a/packages/origin/src/components/Combobox/Combobox.module.scss +++ b/packages/origin/src/components/Combobox/Combobox.module.scss @@ -24,7 +24,10 @@ padding-left: var(--spacing-2xs); } - &:has(.clear:not([hidden])) { + &:has(.clearAlways:not([hidden])), + &:focus-within:has(.clearActive:not([hidden])), + &[data-focused]:has(.clearActive:not([hidden])), + &[data-popup-open]:has(.clearActive:not([hidden])) { padding-right: calc(var(--spacing-xs) + 42px); } @@ -129,7 +132,6 @@ .clear { box-sizing: border-box; - display: flex; align-items: center; justify-content: center; flex-shrink: 0; @@ -157,6 +159,20 @@ } } +.clearActive { + display: none; +} + +.inputWrapper:focus-within .clearActive:not([hidden]), +.inputWrapper[data-focused] .clearActive:not([hidden]), +.inputWrapper[data-popup-open] .clearActive:not([hidden]) { + display: flex; +} + +.clearAlways:not([hidden]) { + display: flex; +} + .clear svg { width: 17px; height: 17px; diff --git a/packages/origin/src/components/Combobox/Combobox.stories.tsx b/packages/origin/src/components/Combobox/Combobox.stories.tsx index 7e67d27a3..4ad121e0f 100644 --- a/packages/origin/src/components/Combobox/Combobox.stories.tsx +++ b/packages/origin/src/components/Combobox/Combobox.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { useState } from "react"; -import { Combobox } from "./index"; +import { Combobox, type ComboboxClearProps } from "./index"; import { Field } from "@/components/Field"; const meta: Meta = { @@ -36,6 +36,14 @@ export const Default: Story = { args: { disabled: false, }, + parameters: { + docs: { + description: { + story: + "No-clear reference. Omit Combobox.Clear when a flow should not expose a clear affordance.", + }, + }, + }, render: (args) => ( @@ -91,13 +99,22 @@ export const LongList: Story = { ), }; -export const WithClear: Story = { - render: () => ( +function ClearStory({ + visibility, + label, +}: { + visibility?: ComboboxClearProps["visibility"]; + label: string; +}) { + return ( - + - + @@ -117,7 +134,32 @@ export const WithClear: Story = { - ), + ); +} + +export const WithClear: Story = { + name: "Default Active Clear", + parameters: { + docs: { + description: { + story: + "Default edit-existing flow. The clear affordance is hidden at rest, appears while the field is focused or open, and is removed by Base UI once the value is cleared.", + }, + }, + }, + render: () => , +}; + +export const AlwaysClear: Story = { + parameters: { + docs: { + description: { + story: + "Opt in when a clear affordance should remain visible while the value is clearable. Base UI still removes it once the value is cleared.", + }, + }, + }, + render: () => , }; function MultipleField({ diff --git a/packages/origin/src/components/Combobox/Combobox.test-stories.tsx b/packages/origin/src/components/Combobox/Combobox.test-stories.tsx index aeddfc107..7b435533a 100644 --- a/packages/origin/src/components/Combobox/Combobox.test-stories.tsx +++ b/packages/origin/src/components/Combobox/Combobox.test-stories.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { useState } from "react"; -import { Combobox } from "./index"; +import { Combobox, type ComboboxClearProps } from "./index"; const fruits = [ "Apple", @@ -307,12 +307,22 @@ export const TestComboboxWithGroups = () => ( ); -export const TestComboboxWithClear = () => ( +export const TestComboboxWithClear = ({ + clearClassName, + visibility, +}: { + clearClassName?: ComboboxClearProps["className"]; + visibility?: ComboboxClearProps["visibility"]; +} = {}) => ( - + - + @@ -333,3 +343,10 @@ export const TestComboboxWithClear = () => ( ); + +export const TestComboboxWithClearClassNameCallback = () => ( + (state.open ? "clear-open" : "clear-closed")} + visibility="always" + /> +); diff --git a/packages/origin/src/components/Combobox/Combobox.test.tsx b/packages/origin/src/components/Combobox/Combobox.test.tsx index f114add4f..f68d0c468 100644 --- a/packages/origin/src/components/Combobox/Combobox.test.tsx +++ b/packages/origin/src/components/Combobox/Combobox.test.tsx @@ -8,6 +8,7 @@ import { TestComboboxControlled, TestComboboxWithGroups, TestComboboxWithClear, + TestComboboxWithClearClassNameCallback, TestComboboxChipPassThrough, ConformanceInputWrapper, ConformanceActionButtons, @@ -270,14 +271,103 @@ test.describe("Combobox", () => { }); test.describe("clear button", () => { - test("clears selection on click", async ({ mount }) => { + test("defaults to active visibility and clears selection on click", async ({ + mount, + page, + }) => { const component = await mount(); const input = component.getByPlaceholder("Select a fruit..."); - const clear = component.locator('button[class*="clear"]'); + const clear = component.getByRole("button", { + name: "Clear selection", + includeHidden: true, + }); + + await expect(input).toHaveValue("Apple"); + await expect(clear).toHaveCSS("display", "none"); + + await input.click(); + await expect(page.getByRole("listbox")).toBeVisible(); + await expect(clear).toBeVisible(); + await clear.click(); + await expect(input).toHaveValue(""); + }); + + test("always clear remains visible while clearable", async ({ mount }) => { + const component = await mount( + , + ); + const input = component.getByPlaceholder("Select a fruit..."); + const clear = component.getByRole("button", { name: "Clear selection" }); await expect(input).toHaveValue("Apple"); + await expect(clear).toBeVisible(); + await expect(clear).not.toHaveAttribute("visibility", "always"); + await expect(clear).not.toHaveAttribute( + "data-clear-visibility", + "always", + ); await clear.click(); await expect(input).toHaveValue(""); + await expect(clear).toBeHidden(); + }); + + test("preserves Base UI stateful clear className callback", async ({ + mount, + }) => { + const component = await mount(); + const clear = component.getByRole("button", { name: "Clear selection" }); + + await expect(clear).toHaveClass(/clear-closed/); + }); + + test("active clear is hidden at rest and appears while focused or open", async ({ + mount, + page, + }) => { + const component = await mount( + , + ); + const input = component.getByPlaceholder("Select a fruit..."); + const wrapper = page.getByTestId("combobox-clear-wrapper"); + const clear = component.getByRole("button", { + name: "Clear selection", + includeHidden: true, + }); + + const restingPadding = await wrapper.evaluate((element) => + Number.parseFloat(window.getComputedStyle(element).paddingRight), + ); + + await expect(input).toHaveValue("Apple"); + await expect(clear).toHaveCSS("display", "none"); + await expect( + component.getByRole("button", { name: "Clear selection" }), + ).toBeHidden(); + + await input.click(); + await expect(page.getByRole("listbox")).toBeVisible(); + await expect(clear).toBeVisible(); + + const activePadding = await wrapper.evaluate((element) => + Number.parseFloat(window.getComputedStyle(element).paddingRight), + ); + expect(activePadding).toBeGreaterThan(restingPadding); + + await clear.click(); + await expect(input).toHaveValue(""); + }); + + test("omitting Clear does not render the clear affordance", async ({ + mount, + }) => { + const component = await mount(); + + await expect( + component.getByRole("button", { + name: "Clear selection", + includeHidden: true, + }), + ).toHaveCount(0); }); }); diff --git a/packages/origin/src/components/Combobox/index.ts b/packages/origin/src/components/Combobox/index.ts index b1c995f8e..c1601d3c1 100644 --- a/packages/origin/src/components/Combobox/index.ts +++ b/packages/origin/src/components/Combobox/index.ts @@ -9,6 +9,7 @@ export type { ActionButtonsProps as ComboboxActionButtonsProps, TriggerProps as ComboboxTriggerProps, ClearProps as ComboboxClearProps, + ClearVisibility as ComboboxClearVisibility, PortalProps as ComboboxPortalProps, PositionerProps as ComboboxPositionerProps, PopupProps as ComboboxPopupProps, diff --git a/packages/origin/src/components/Combobox/parts.tsx b/packages/origin/src/components/Combobox/parts.tsx index ccdb6b8e8..d616accd1 100644 --- a/packages/origin/src/components/Combobox/parts.tsx +++ b/packages/origin/src/components/Combobox/parts.tsx @@ -141,22 +141,53 @@ export const Trigger = React.forwardRef( }, ); -export interface ClearProps extends BaseCombobox.Clear.Props {} +export type ClearVisibility = "always" | "active"; + +export interface ClearProps + extends Omit { + /** + * Controls when Origin shows the clear affordance. Base UI still owns the + * clear behavior and whether a value is currently clearable. Defaults to + * "active", which shows the affordance while the field is focused or open. + */ + visibility?: ClearVisibility; + /** + * Unsupported in Origin. Clear visibility relies on Base UI unmounting or + * hiding the button when the value is not clearable. + */ + keepMounted?: never; +} /** * Combobox.Clear - Button to clear the selection. * * Renders as a small icon button with the X icon. - * Uses Base UI's default behavior - only visible when there's a value to clear. + * Uses Base UI's default behavior - only rendered when there's a value to clear. */ export const Clear = React.forwardRef( - function Clear({ className, children, ...props }, ref) { + function Clear( + { + className, + children, + keepMounted: _keepMounted, + visibility = "active", + ...props + }, + ref, + ) { + const originClassName = [ + styles.clear, + visibility === "active" && styles.clearActive, + visibility === "always" && styles.clearAlways, + ]; + const clearClassName = + typeof className === "function" + ? (state: BaseCombobox.Clear.State) => + clsx(originClassName, className(state)) + : clsx(originClassName, className); + return ( - + {children ?? } ); diff --git a/packages/origin/src/index.ts b/packages/origin/src/index.ts index 0b39f5706..45712aeb4 100644 --- a/packages/origin/src/index.ts +++ b/packages/origin/src/index.ts @@ -37,6 +37,10 @@ export type { export { Checkbox } from "./components/Checkbox"; export { Command } from "./components/Command"; export { Combobox } from "./components/Combobox"; +export type { + ComboboxClearProps, + ComboboxClearVisibility, +} from "./components/Combobox"; export { ContextMenu } from "./components/ContextMenu"; export { Dialog } from "./components/Dialog"; export { Drawer, createHandle } from "./components/Drawer"; diff --git a/packages/origin/tsconfig.json b/packages/origin/tsconfig.json index c964159ec..41b42e44d 100644 --- a/packages/origin/tsconfig.json +++ b/packages/origin/tsconfig.json @@ -15,7 +15,7 @@ "@test-utils/*": ["./test-utils/*"] } }, - "include": ["src/**/*.ts", "src/**/*.tsx"], + "include": ["src/**/*.ts", "src/**/*.tsx", "type-tests/**/*.tsx"], "exclude": [ "node_modules", "tools", diff --git a/packages/origin/type-tests/combobox-clear.tsx b/packages/origin/type-tests/combobox-clear.tsx new file mode 100644 index 000000000..bdb448fc7 --- /dev/null +++ b/packages/origin/type-tests/combobox-clear.tsx @@ -0,0 +1,7 @@ +import { Combobox } from "../src"; + + (state.open ? "open" : "closed")} />; + +// Origin clear visibility depends on Base UI owning mount/hidden state. +// @ts-expect-error keepMounted is intentionally not part of the Origin API. +; From ddf20c0cfdcf56ec4f914df7aed656c0746be4d4 Mon Sep 17 00:00:00 2001 From: kphurley7 Date: Wed, 10 Jun 2026 19:11:44 -0700 Subject: [PATCH 68/90] chore(js): remove unused static logo asset (#28518) ## Summary Removes the unused `js/packages/static/images/lightspark-logo.svg` asset. The `@lightsparkdev/static` workspace metadata is intentionally kept so this cleanup does not require a `js/yarn.lock` update. Fully removing the workspace package would require a JS dependency maintainer to recreate the lockfile change because CI gates lockfile changes by push actor. - The SVG is referenced nowhere in code. - The `static/images/...` imports elsewhere resolve to `packages/ui/src/static/`, not this package. - The old infra sync for `js/packages/static/` was removed after the logo moved to `sparkcore/sparkcore/static/`. ## Why this PR should exist The asset is orphaned and creates audit noise. Removing only the unused file is low-risk and avoids unrelated dependency-lockfile churn. ## Testing - `yarn install --immutable` - `rg -n '@lightsparkdev/static|packages/static|static/images/lightspark-logo|lightspark-logo.svg' js -g '!yarn.lock'` --------- Co-authored-by: Claude Fable 5 GitOrigin-RevId: f19bf82424df3ddb155dcb608bf22ad9f2f75e43 --- packages/static/images/lightspark-logo.svg | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 packages/static/images/lightspark-logo.svg diff --git a/packages/static/images/lightspark-logo.svg b/packages/static/images/lightspark-logo.svg deleted file mode 100644 index 387d07084..000000000 --- a/packages/static/images/lightspark-logo.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - From 779ce90665e5d1d76e23db6f2a1b5db945e7eeb4 Mon Sep 17 00:00:00 2001 From: Jay Mantri Date: Thu, 11 Jun 2026 08:43:18 -0700 Subject: [PATCH 69/90] DES-51: add Nage phone input foundation (#28445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Recreates the DES-51 Nage phone input foundation on a fresh branch from current `origin/main` to avoid the superseded PR's lockfile guard history issue. - Introduces E.164-only parsing helpers plus an Origin `PhoneInput`/`Field` backed component using local round country flag assets. - Refactors the field around a single parsed draft model, keeps external `value` as canonical E.164, and delays internal validation visibility until blur unless a previously accepted value becomes invalid. - Covers country-change behavior, controlled value sync, validation notifications, repeated invalid edits, delayed internal error visibility, and the `+447911123456` Crown Dependency case as `GG`, with field tests using real Origin components and role-based queries. ## Scope - Foundation files only under `js/apps/private/site/src/uma-nage/components/phone-input/`. - No package version bump files, KYB, payouts, settings, or other product callsite migrations in this PR. - Supersedes #28414. ## Test plan - `mise exec -- node -v` from `js` — `v20.19.6`. - `mise exec -- yarn workspace @lightsparkdev/site exec prettier --check src/uma-nage/components/phone-input/NagePhoneInputField.tsx src/uma-nage/components/phone-input/NagePhoneInputField.test.tsx` from `js` — passed. - `mise exec -- yarn workspace @lightsparkdev/site exec eslint src/uma-nage/components/phone-input/NagePhoneInputField.tsx src/uma-nage/components/phone-input/NagePhoneInputField.test.tsx` from `js` — passed. - `mise exec -- yarn workspace @lightsparkdev/site exec vitest run src/uma-nage/components/phone-input/NagePhoneInputField.test.tsx src/uma-nage/components/phone-input/phoneNumber.test.ts` from `js` — 30 tests passed. Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor GitOrigin-RevId: 5ada43a118245619dbf0fc52058399ed8e336980 --- packages/ui/package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ui/package.json b/packages/ui/package.json index 4c98e1636..daa57d14e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -19,6 +19,10 @@ "import": "./dist/components/*.js", "require": "./dist/components/*.cjs" }, + "./components/CountryFlag": { + "import": "./dist/components/CountryFlag/index.js", + "require": "./dist/components/CountryFlag/index.cjs" + }, "./components/typography": { "import": "./dist/components/typography/index.js", "require": "./dist/components/typography/index.cjs" From 4bd38aab7d0b1723928798e3b8633b631680d5c2 Mon Sep 17 00:00:00 2001 From: Corey Martin Date: Thu, 11 Jun 2026 15:55:55 -0700 Subject: [PATCH 70/90] [site] Fix preview login redirects (#28555) ## Summary - Normalize post-login redirect targets against the active Vite/router basename. - Prevent UI preview links from redirecting to duplicated paths like `/preview/pr-28544/preview/pr-28544/`. - Preserve the existing full-page redirect behavior for `/ops` paths, including preview ops paths. ## Testing - `yarn workspace @lightsparkdev/site exec vitest run src/hooks/loginRedirect.test.ts` - `yarn workspace @lightsparkdev/site exec eslint src/hooks/useLoginAndRedirect.tsx src/hooks/loginRedirect.ts src/hooks/loginRedirect.test.ts` - `yarn workspace @lightsparkdev/site exec prettier --check src/hooks/useLoginAndRedirect.tsx src/hooks/loginRedirect.ts src/hooks/loginRedirect.test.ts` ## Notes - Full `@lightsparkdev/site` typecheck is blocked locally by existing unresolved workspace package exports such as `@lightsparkdev/ui/router`. GitOrigin-RevId: d0319351285faf26145bf5d920525218a47fbcad --- packages/ui/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index daa57d14e..ed3980455 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -83,9 +83,9 @@ "./src/router": "./src/router.js" }, "scripts": { - "build:bundle": "tsdown", + "build:bundle": "tsdown --config-loader unrun", "build": "yarn tsc && yarn build:bundle", - "build:watch": "tsdown --watch --no-clean", + "build:watch": "tsdown --config-loader unrun --watch --no-clean", "circular-deps": "madge --circular --extensions ts,tsx .", "clean": "rm -rf .turbo", "format:fix": "prettier . --write", From 909a749816db2624a49ee377bd226f0420f06fde Mon Sep 17 00:00:00 2001 From: Jay Mantri Date: Thu, 11 Jun 2026 16:33:37 -0700 Subject: [PATCH 71/90] [grid] Add Receive/Add Funds money-flow primitives (#27951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds reusable Receive/Add Funds primitives for platform and customer scopes: account selection, amount entry, setup rows, funds-target helpers, funding-instruction rendering, and sandbox funding support. - Requires active, eligible internal accounts before opening the new Add Funds flow, revalidates stale selected accounts, and hardens sandbox busy/close lifecycle behavior. - Preserves existing read-only funding-instruction access where users can view instructions but cannot start write actions. ## Gate / rollout notes - Home and Customers Add Funds surfacing is gated by `GRID_DASHBOARD_ADD_FUNDS_ENABLED`. It does not gate existing read-only funding instructions. - Payouts Add Funds V2 is independently gated by `GRID_DASHBOARD_PAYOUTS_ADD_FUNDS_V2_ENABLED`, allowing Payouts to roll out separately from Home/Customer Add Funds. - Sandbox funding mutation is sandbox-only and remains behind the explicit Add Funds flow; production funding instructions stay read-only display unless a permitted action is available. - Role/write permissions are authorization checks enforced inside the flow/provider and are separate from rollout GKs. - This PR does not add Transfer, Withdraw, Send, payout creation, or external-account ownership semantics. ## Test plan / validation - Focused Add Funds provider, customer/home/internal account drawer, Sandbox Funding, receive/add-funds target, funds-target, money input, account display, and funding-instruction tests passed. - Type/lint validation passed for the touched Grid UI areas. - Boundary checks were run against the previous stack slice to keep this PR scoped to Receive/Add Funds behavior. ## How to review This is large (+8,940 / −1,255 across 54 files vs main), but ~4,100 of the inserted lines (~46%) are tests and test utilities. The biggest non-test files are `receive-add-funds/ReceiveInstructions.tsx` (+596), `payouts/panels/LegacyAddFundsPanel.tsx` (+565), `receive-add-funds/SandboxFundingSection.tsx` (+374), `payouts/AddFundsFlowProvider.tsx` (+344/−72), and `receive-add-funds/useAddFundsDrawer.tsx` (+294). ### What changed since your last review - **Funds-target unification** (`a3a605a813`, renamed in `37fac7e8d7`): the new Add Funds flow now keys off a shared `FundsTarget` in `utils/fundsTarget.ts` — formerly `OwnerScope` / `utils/ownerScope.ts` — which replaced payouts' original funds-target type. Only `LegacyFundsTarget` remains, confined to `payouts/LegacyAddFundsFlowProvider.tsx`. - **Currency eligibility guard** (`6965c94e48`): `receive-add-funds/receiveAddFundsTarget.ts` (formerly `receiveAddFundsScope.ts`) now owns a shared eligibility check that excludes accounts with incomplete currency data, used by both Home and customer scopes. It still imports `allowedFundingCurrencies` from `payouts/utils/`; relocating that module and adding a cross-flow eligibility rule-matrix test is tracked in [DES-65](https://lightspark.atlassian.net/browse/DES-65). - **Drawer machine unification** (`2ed9f32365`): new `receive-add-funds/useAddFundsDrawer.tsx` (294 lines) holds the open/busy/close state machine that Home and the customer drawer previously each implemented. `Home.tsx` dropped ~195 lines; `customers/CustomerAddFundsDrawer.tsx` is now a 54-line shim that binds the customer funds target and renders the shared drawer. Payouts' `AddFundsFlowProvider` is intentionally not folded in — it goes away with the legacy retirement tracked in [DES-57](https://lightspark.atlassian.net/browse/DES-57). ### Suggested reading order 1. Target/eligibility model: `utils/fundsTarget.ts`, `receive-add-funds/receiveAddFundsTarget.ts` 2. The drawer machine: `receive-add-funds/useAddFundsDrawer.tsx` 3. Call sites: `home/Home.tsx`, `customers/CustomerAddFundsDrawer.tsx` 4. Payouts panel changes: `payouts/AddFundsFlowProvider.tsx`, `payouts/panels/AddFundsPanel.tsx` 5. Tests ### Gating All new surfacing is behind `GRID_DASHBOARD_ADD_FUNDS_ENABLED` (Home/customer entry points, which themselves sit behind the `GRID_DASHBOARD_HOME_ENABLED` / `GRID_DASHBOARD_CUSTOMER_PROFILE_ENABLED` route gates) and `GRID_DASHBOARD_PAYOUTS_ADD_FUNDS_V2_ENABLED` for the payouts path. [DES-65]: https://lightspark.atlassian.net/browse/DES-65?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Cursor GitOrigin-RevId: d2255efa0b2346a8a7841856f0acf510dee9add0 --- packages/ui/src/icons/BaseNetwork.tsx | 6 ++++-- packages/ui/src/icons/PolygonNetwork.tsx | 23 +++++++++++++++++++++-- packages/ui/src/icons/TronNetwork.tsx | 11 +++++++---- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/icons/BaseNetwork.tsx b/packages/ui/src/icons/BaseNetwork.tsx index ad9ec7de4..82d0b9d20 100644 --- a/packages/ui/src/icons/BaseNetwork.tsx +++ b/packages/ui/src/icons/BaseNetwork.tsx @@ -9,8 +9,10 @@ export function BaseNetwork() { fill="none" viewBox="0 0 24 24" > - - + ); } diff --git a/packages/ui/src/icons/PolygonNetwork.tsx b/packages/ui/src/icons/PolygonNetwork.tsx index b3415d537..f54a69c0e 100644 --- a/packages/ui/src/icons/PolygonNetwork.tsx +++ b/packages/ui/src/icons/PolygonNetwork.tsx @@ -1,6 +1,11 @@ // Copyright ©, 2022, Lightspark Group, Inc. - All Rights Reserved +import { useId } from "react"; + export function PolygonNetwork() { + const uid = useId(); + const gradientId = `polygon-network__a-${uid}`; + return ( + + + + + + + ); } diff --git a/packages/ui/src/icons/TronNetwork.tsx b/packages/ui/src/icons/TronNetwork.tsx index c034bd8df..4e9f5ef94 100644 --- a/packages/ui/src/icons/TronNetwork.tsx +++ b/packages/ui/src/icons/TronNetwork.tsx @@ -9,10 +9,13 @@ export function TronNetwork() { fill="none" viewBox="0 0 24 24" > - + + + + ); } From a3b7643d4a1cb290612701a4e29cbf6981e98d17 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 12 Jun 2026 02:43:25 -0700 Subject: [PATCH 72/90] [js] gga example app: V3 secure OTP e2e flow (test harness) (#28154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a **Run V3 flow** button to the EMAIL_OTP tab of the Grid Global Accounts example app that exercises the secure OTP login end-to-end: 1. `POST /auth/credentials/{id}/challenge` → `otpEncryptionTargetBundle` 2. HPKE-seal `{clientPublicKey, otpCodeAttempt}` locally via `@turnkey/crypto` (`hpkeEncrypt` + `bs58check`) 3. `POST /verify` `{encryptedOtpBundle}` → **202** + `verificationToken` 4. sign the token with the TEK → `Grid-Wallet-Signature` 5. `POST /verify` retry → **200** AuthSession (no `encryptedSessionSigningKey`) Test harness for verifying the V3 flow against sandbox/prod. The OTP code never leaves the client in plaintext and the TEK private key stays client-side. Verified: `tsc` clean, and a cross-language round-trip (JS `@turnkey/crypto` seal ↔ Python sandbox `open_encrypted_otp_bundle`) passes. Depends on the secure-OTP backend stack (base PR). 🤖 Generated with [Claude Code](https://claude.com/claude-code) GitOrigin-RevId: 8c6684bc6487de26e0df0b51e3e207a63631f12f --- .../index.html | 32 ++-- .../package.json | 3 +- .../src/main.ts | 173 ++++++++++++++++-- .../vite.config.ts | 6 +- 4 files changed, 180 insertions(+), 34 deletions(-) diff --git a/apps/examples/grid-global-accounts-example-app/index.html b/apps/examples/grid-global-accounts-example-app/index.html index 7626144f4..e0703fb8f 100644 --- a/apps/examples/grid-global-accounts-example-app/index.html +++ b/apps/examples/grid-global-accounts-example-app/index.html @@ -400,21 +400,23 @@

Create credential

-

Verify → session

- - - - - - -
+

Verify → session (secure OTP)

+

+ Two steps. 1. Challenge issues INIT_OTP and returns the + enclave target bundle — against real Turnkey the OTP is emailed to + the customer; in sandbox it's 000000. 2. Verify + HPKE-seals the entered code with @turnkey/crypto → + /verify first leg (202 + verificationToken) → ECDSA-sign the token + with the TEK → /verify retry (200 session). The code is never sent + in plaintext and the TEK private key never leaves the client. Uses + the Credential ID from Wallet Context. +

+ +
+ + + +
diff --git a/apps/examples/grid-global-accounts-example-app/package.json b/apps/examples/grid-global-accounts-example-app/package.json index 81c26423b..01f6f7586 100644 --- a/apps/examples/grid-global-accounts-example-app/package.json +++ b/apps/examples/grid-global-accounts-example-app/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@turnkey/api-key-stamper": "^0.6.5", - "@turnkey/crypto": "^2.8.14" + "@turnkey/crypto": "^2.8.14", + "@turnkey/encoding": "^0.6.0" } } diff --git a/apps/examples/grid-global-accounts-example-app/src/main.ts b/apps/examples/grid-global-accounts-example-app/src/main.ts index 2fc905e39..8bf226b80 100644 --- a/apps/examples/grid-global-accounts-example-app/src/main.ts +++ b/apps/examples/grid-global-accounts-example-app/src/main.ts @@ -5,7 +5,13 @@ // 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 { + decryptCredentialBundle, + formatHpkeBuf, + generateP256KeyPair, + getPublicKey, + hpkeEncrypt, +} from "@turnkey/crypto"; import { signWithApiKey } from "@turnkey/api-key-stamper"; type Mode = "sandbox" | "production"; @@ -110,6 +116,70 @@ async function turnkeyStamp(payload: string): Promise { return btoa(json).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } +// ----- V3 secure OTP client crypto ----- +// +// HPKE-seal {clientPublicKey, otpCodeAttempt} under the enclave's +// `otpEncryptionTargetBundle`. That bundle is a signed enclave envelope — +// {version, data, dataSignature, enclaveQuorumPublic} — where `data` is a +// hex-encoded JSON blob carrying the enclave's uncompressed HPKE target key as +// `targetPublic`. We pull `targetPublic` out, HPKE-encrypt under it, and emit +// Turnkey's `formatHpkeBuf` wire shape {"encappedPublic","ciphertext"} — exactly +// what `@turnkey/crypto`'s `encryptPrivateKeyToBundle` produces for the +// analogous key-import flow. (A production client would also verify +// `dataSignature` against `enclaveQuorumPublic`; skipped here because the bundle +// originates from our own backend in this test app.) +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function sealOtpBundle( + targetBundle: string, + clientPublicKeyHex: string, + otpCode: string, +): string { + const parsed = JSON.parse(targetBundle) as { data: string }; + const signedData = JSON.parse( + new TextDecoder().decode(hexToBytes(parsed.data)), + ) as { targetPublic: string }; + const targetKeyBuf = hexToBytes(signedData.targetPublic); // 65-byte uncompressed + const plainTextBuf = new TextEncoder().encode( + // The enclave expects snake_case {otp_code, public_key} — NOT the + // {clientPublicKey, otpCodeAttempt} shown in Turnkey's docs sequence + // diagram. Matches @turnkey/crypto's encryptOtpCodeToBundle. + JSON.stringify({ otp_code: otpCode, public_key: clientPublicKeyHex }), + ); + const encryptedBuf = hpkeEncrypt({ plainTextBuf, targetKeyBuf }); // compressed_enc[33] || ciphertext + return formatHpkeBuf(encryptedBuf); // {"encappedPublic","ciphertext"} +} + +// Build the `Grid-Wallet-Signature` stamp over the verificationToken using a +// specific keypair (the V3 TEK), not the session key — base64url(JSON({ +// publicKey, scheme, signature})), the shape `parse_api_key_stamp` expects. +async function buildWalletSignature( + publicKeyHex: string, + privateKeyHex: string, + payload: string, +): Promise { + const signature = await signWithApiKey({ + content: payload, + publicKey: publicKeyHex, + privateKey: privateKeyHex, + }); + const stamp = { + publicKey: publicKeyHex, + scheme: TURNKEY_STAMP_SCHEME, + signature, + }; + return btoa(JSON.stringify(stamp)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + // ----- DOM helpers ----- function el(id: string): T { @@ -494,26 +564,99 @@ bindClick( }, ); -wireGenKeyButton("btn-email_otp-verify-genkey", "email_otp-verify-pubkey"); +// Secure OTP — two steps so it works against real Turnkey, which emails a +// real OTP (sandbox uses the fixed 000000). Step 1 (/challenge) issues the +// INIT_OTP and returns the enclave's target bundle, held below until Verify +// consumes it. Step 2 +// HPKE-seals the entered code under that bundle, runs /verify first leg +// (202 + payloadToSign), signs the token with the TEK, and runs /verify retry +// (200 session). The code never leaves the client in plaintext; the TEK private +// key stays client-side (no encryptedSessionSigningKey is returned). + +// Target bundle from the most recent V3 challenge + the credential it was +// issued for, so Verify catches a stale/mismatched bundle. +let v3TargetBundle: string | null = null; +let v3TargetBundleCredId: string | null = null; + bindClick( - "btn-email_otp-verify", - "email_otp-verify-status", - "EMAIL_OTP Verify", + "btn-email_otp-v3-challenge", + "email_otp-v3-challenge-status", + "EMAIL_OTP Challenge (V3)", + "Requesting OTP...", + async () => { + const credId = requireCredentialId(); + const { data: challengeData } = await apiPost( + `/auth/credentials/${encodeURIComponent(credId)}/challenge`, + {}, + ); + addLog("V3 Challenge", challengeData); + const targetBundle = (challengeData as Record) + .otpEncryptionTargetBundle as string | undefined; + if (!targetBundle) + throw new Error( + "Challenge response missing otpEncryptionTargetBundle — is the local " + + "backend running the secure-OTP branch?", + ); + v3TargetBundle = targetBundle; + v3TargetBundleCredId = credId; + return "OTP sent. Check the customer's email, enter the code below, then Verify."; + }, +); + +bindClick( + "btn-email_otp-v3-verify", + "email_otp-v3-verify-status", + "EMAIL_OTP Verify (V3)", "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( + const otp = el("email_otp-v3-code").value.trim(); + if (!otp) throw new Error("OTP code is required."); + if (!v3TargetBundle || v3TargetBundleCredId !== credId) + throw new Error( + "Run Challenge (V3) first to request an OTP + target bundle for this " + + "credential.", + ); + + // Generate a TEK and HPKE-seal the entered OTP under the challenge bundle. + const tek = generateP256KeyPair(); + const encryptedOtpBundle = sealOtpBundle(v3TargetBundle, tek.publicKey, otp); + + // First leg → expect 202 with payloadToSign (verificationToken) + requestId. + const leg1 = await apiPost( `/auth/credentials/${encodeURIComponent(credId)}/verify`, - { type: "EMAIL_OTP", otp, clientPublicKey: pubkey }, + { type: "EMAIL_OTP", encryptedOtpBundle }, ); - 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); + const l1 = (leg1.data ?? {}) as Record; + addLog("V3 Verify leg 1 (expect 202)", { status: leg1.status, ...l1 }); + const payloadToSign = l1.payloadToSign as string | undefined; + const requestId = l1.requestId as string | undefined; + if (leg1.status !== 202 || !payloadToSign || !requestId) + throw new Error(`Unexpected first-leg response: ${JSON.stringify(leg1)}`); + + // Sign the verificationToken with the TEK private key. + const signature = await buildWalletSignature( + tek.publicKey, + tek.privateKey, + payloadToSign, + ); + + // Retry with the signature → expect 200 AuthSession. + const leg2 = await apiPost( + `/auth/credentials/${encodeURIComponent(credId)}/verify`, + { type: "EMAIL_OTP", encryptedOtpBundle }, + { "Grid-Wallet-Signature": signature, "Request-Id": requestId }, + ); + const session = (leg2.data ?? {}) as Record; + addLog("V3 Verify leg 2 (expect 200 session)", { + status: leg2.status, + ...session, + }); + if (session.id) setCtxSession(session.id as string); + // One bundle per challenge — force a fresh Challenge for the next run. + v3TargetBundle = null; + v3TargetBundleCredId = null; + return JSON.stringify({ leg1: leg1.data, session: leg2.data }, null, 2); }, ); diff --git a/apps/examples/grid-global-accounts-example-app/vite.config.ts b/apps/examples/grid-global-accounts-example-app/vite.config.ts index 0513947cb..7c5112695 100644 --- a/apps/examples/grid-global-accounts-example-app/vite.config.ts +++ b/apps/examples/grid-global-accounts-example-app/vite.config.ts @@ -1,9 +1,9 @@ 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. +// Production 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({ From d256b2dfbbe0873bd28ca26311b278ec2895e2eb Mon Sep 17 00:00:00 2001 From: Lightspark Eng Date: Fri, 12 Jun 2026 09:50:27 +0000 Subject: [PATCH 73/90] CI update lock file for PR --- yarn.lock | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 7cb2d993a..87b117f87 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3183,6 +3183,7 @@ __metadata: dependencies: "@turnkey/api-key-stamper": "npm:^0.6.5" "@turnkey/crypto": "npm:^2.8.14" + "@turnkey/encoding": "npm:^0.6.0" typescript: "npm:^5.6.2" vite: "npm:^8.0.14" languageName: unknown @@ -5743,7 +5744,7 @@ __metadata: languageName: node linkType: hard -"@turnkey/encoding@npm:0.6.0": +"@turnkey/encoding@npm:0.6.0, @turnkey/encoding@npm:^0.6.0": version: 0.6.0 resolution: "@turnkey/encoding@npm:0.6.0" dependencies: From 004dae250789677011e8c4f91a66fee6809015fc Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 12 Jun 2026 02:50:48 -0700 Subject: [PATCH 74/90] [js] gga example app: real WebAuthn ceremony + OTP-session caching; env-driven grid URL (#28470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Wire the grid-global-accounts example app to a **real WebAuthn ceremony** (Touch ID register/sign) with OTP-session-key caching, and make the dev backend URL **env-driven** (`GRID_URL` env var, defaulting to production) instead of a hardcoded dev host. ## Why P4 example app, PR 0 in `40-example-app-design.md` §5/§6. The branch carried uncommitted real-WebAuthn-ceremony + OTP-session-caching work plus a config hazard: `vite.config.ts` had `PROD_GRID_URL` pointed at a dev host (`api.dev.dev.sparkinfra.net`), a local convenience that must not land in git. This PR commits the in-flight work and un-breaks the config so dev pointing is local-only (`GRID_URL=... yarn dev`) and never committed. ## Place in the stack Base: `06-02-_js_gga_example_app_add_v3_secure_otp_e2e_flow` (the branch holding the uncommitted V3-OTP e2e work). First PR of the **P4 example-app** stack. ## Notable points - `PROD_GRID_URL` now resolves from `process.env.GRID_URL ?? "https://api.lightspark.com"` — production by default, dev override never persisted. - No behavior change beyond what was already live on the branch; manual test tool (no automated UI tests). Type gate: `yarn workspace ... build` (tsc + vite) + `yarn lint && yarn format`. --- Part of the Turnkey login-family migration program. See `sparkcore/sparkcore/grid/docs/login-migration/00-program-plan.md`. GitOrigin-RevId: 8c7d9891683ccf6739b7045c753e2c4b0456803e --- .../index.html | 31 +++ .../src/main.ts | 192 +++++++++++++++++- .../vite.config.ts | 10 +- 3 files changed, 228 insertions(+), 5 deletions(-) diff --git a/apps/examples/grid-global-accounts-example-app/index.html b/apps/examples/grid-global-accounts-example-app/index.html index e0703fb8f..1ecb6c84c 100644 --- a/apps/examples/grid-global-accounts-example-app/index.html +++ b/apps/examples/grid-global-accounts-example-app/index.html @@ -652,6 +652,24 @@

Create credential

id="passkey-create-nickname" value="Sandbox Passkey" /> + + +

+ Click below to register a real passkey on this + device (Touch ID) — it fills the attestation fields. Use it for both + "Create" and "Add additional" against real Turnkey. The sub-org's RP + ID must match this page's origin. +

+ +
Verify → session id="passkey-verify-client-data-json" value="c2FuZGJveC1jbGllbnQtZGF0YQ" /> +

+ Click below to sign the issued challenge with your real passkey + (Touch ID) — it fills the assertion fields above. +

+ +
@@ -749,6 +775,11 @@

Verify → session

Add additional PASSKEY via signed retry

+

+ First click "📱 Create real passkey" in the + Create credential section above — that registers the new + passkey whose attestation is added here. Then run steps 1 and 2. +

diff --git a/apps/examples/grid-global-accounts-example-app/src/main.ts b/apps/examples/grid-global-accounts-example-app/src/main.ts index 8bf226b80..18ac8f80e 100644 --- a/apps/examples/grid-global-accounts-example-app/src/main.ts +++ b/apps/examples/grid-global-accounts-example-app/src/main.ts @@ -73,6 +73,20 @@ function rememberEncryptedSessionSigningKey(value: unknown): void { } } +// OTP_LOGIN / STAMP_LOGIN model: there is no encryptedSessionSigningKey bundle +// — the TEK private key *is* the session's API key once login registers it. +// Cache it directly so turnkeyStamp() can authorize later signed retries +// (e.g. adding a passkey) without the Verify-style clientKeyPair + bundle. +function setSessionKeysFromTek(tek: { + publicKey: string; + privateKey: string; +}): void { + cachedSessionKeys = { + apiPublicKey: tek.publicKey, + apiPrivateKey: tek.privateKey, + }; +} + function decryptSessionKeysOrThrow(): SessionKeys { if (cachedSessionKeys) return cachedSessionKeys; if (!clientKeyPair) @@ -653,6 +667,10 @@ bindClick( ...session, }); if (session.id) setCtxSession(session.id as string); + // The TEK is now the session's API key (OTP_LOGIN registered it). Cache it + // as the active session signing key so later signed retries (add passkey, + // quote execute, etc.) can stamp with this session via turnkeyStamp(). + if (leg2.status === 200) setSessionKeysFromTek(tek); // One bundle per challenge — force a fresh Challenge for the next run. v3TargetBundle = null; v3TargetBundleCredId = null; @@ -811,6 +829,119 @@ bindClick( }, ); +// ----- WebAuthn ceremony helpers (real passkeys) ----- +// +// The sandbox flows accept magic placeholder strings, but a real Turnkey +// sub-org needs a genuine WebAuthn credential. These helpers drive the +// browser's authenticator (Touch ID, etc.) and base64url-encode the results +// into the same fields the sandbox flow uses, so Create / Add / Verify work +// unchanged against production Turnkey. +// +// NOTE: WebAuthn binds a credential to an RP ID that must be a suffix of the +// page origin — on localhost that means rpId="localhost". The Turnkey sub-org +// must have been created with the SAME RP ID or verification will fail. + +function bytesToB64Url(bytes: Uint8Array): string { + let bin = ""; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function b64UrlToBytes(value: string): Uint8Array { + const b64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); + const bin = atob(padded); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} + +function passkeyRpId(): string { + return el("passkey-rp-id").value.trim() || location.hostname; +} + +interface RealAttestation { + challenge: string; + credentialId: string; + clientDataJson: string; + attestationObject: string; +} + +// Real registration ceremony — produces the attestation that Create/Add send. +async function createRealPasskey(nickname: string): Promise { + const challenge = crypto.getRandomValues(new Uint8Array(32)); + const userId = crypto.getRandomValues(new Uint8Array(16)); + const credential = (await navigator.credentials.create({ + publicKey: { + rp: { id: passkeyRpId(), name: "Grid Example App" }, + user: { + id: userId, + name: nickname || "grid-example-user", + displayName: nickname || "Grid Example User", + }, + challenge, + pubKeyCredParams: [ + { type: "public-key", alg: -7 }, + { type: "public-key", alg: -257 }, + ], + authenticatorSelection: { + residentKey: "preferred", + userVerification: "preferred", + }, + attestation: "none", + timeout: 60000, + }, + })) as PublicKeyCredential | null; + if (!credential) throw new Error("Passkey creation returned no credential"); + const response = credential.response as AuthenticatorAttestationResponse; + return { + challenge: bytesToB64Url(challenge), + credentialId: bytesToB64Url(new Uint8Array(credential.rawId)), + clientDataJson: bytesToB64Url(new Uint8Array(response.clientDataJSON)), + attestationObject: bytesToB64Url(new Uint8Array(response.attestationObject)), + }; +} + +interface RealAssertion { + credentialId: string; + authenticatorData: string; + clientDataJson: string; + signature: string; +} + +// Real assertion ceremony — signs the issued session challenge. +async function signWithPasskey( + challengeValue: string, + credentialId: string, +): Promise { + if (!challengeValue) { + throw new Error("No challenge — issue a session challenge (step above) first."); + } + // PR #28427: Turnkey's WebAuthn challenge is the UTF-8 bytes of the + // sha256-hex challenge string returned by /challenge — NOT base64url-decoded. + const challenge = new TextEncoder().encode(challengeValue); + const allowCredentials: PublicKeyCredentialDescriptor[] = credentialId + ? [{ type: "public-key", id: b64UrlToBytes(credentialId) as BufferSource }] + : []; + const credential = (await navigator.credentials.get({ + publicKey: { + rpId: passkeyRpId(), + challenge, + allowCredentials, + userVerification: "preferred", + timeout: 60000, + }, + })) as PublicKeyCredential | null; + if (!credential) throw new Error("Passkey assertion returned no credential"); + const response = credential.response as AuthenticatorAssertionResponse; + return { + credentialId: bytesToB64Url(new Uint8Array(credential.rawId)), + authenticatorData: bytesToB64Url(new Uint8Array(response.authenticatorData)), + clientDataJson: bytesToB64Url(new Uint8Array(response.clientDataJSON)), + signature: bytesToB64Url(new Uint8Array(response.signature)), + }; +} + // ----- PASSKEY ----- bindClick( @@ -838,8 +969,31 @@ bindClick( }, ); +// Drive a real WebAuthn registration (Touch ID) and fill the attestation +// fields above — used by both the "Create" and "Add additional" flows. +bindClick( + "btn-passkey-webauthn-create", + "passkey-webauthn-create-status", + "Passkey Register", + "Waiting for authenticator (Touch ID)...", + async () => { + const nickname = el("passkey-create-nickname").value.trim(); + const att = await createRealPasskey(nickname); + el("passkey-create-challenge").value = att.challenge; + el("passkey-create-cred-id-raw").value = att.credentialId; + el("passkey-create-client-data-json").value = att.clientDataJson; + el("passkey-create-attestation-object").value = + att.attestationObject; + addLog("Passkey Registered (real)", att); + return "Real passkey created — attestation fields filled. Now run Create or Add."; + }, +); + wireGenKeyButton("btn-passkey-challenge-genkey", "passkey-challenge-pubkey"); const passkeyVerifyRequestId = el("passkey-verify-request-id"); +// Captured from the session-challenge response so the real assertion ceremony +// can sign the exact sha256-hex challenge Turnkey expects. +let passkeySessionChallenge = ""; bindClick( "btn-passkey-challenge", "passkey-challenge-status", @@ -856,6 +1010,7 @@ bindClick( addLog("PASSKEY Challenge", data); const d = data as Record; if (d.requestId) passkeyVerifyRequestId.value = d.requestId as string; + if (typeof d.challenge === "string") passkeySessionChallenge = d.challenge; return JSON.stringify(data, null, 2); }, ); @@ -893,7 +1048,30 @@ bindClick( }, ); +// Drive a real WebAuthn assertion (Touch ID) against the issued challenge and +// fill the assertion fields above for Verify. +bindClick( + "btn-passkey-webauthn-get", + "passkey-webauthn-get-status", + "Passkey Sign", + "Waiting for authenticator (Touch ID)...", + async () => { + const credId = el("passkey-create-cred-id-raw").value.trim(); + const assertion = await signWithPasskey(passkeySessionChallenge, credId); + el("passkey-create-cred-id-raw").value = assertion.credentialId; + el("passkey-verify-client-data-json").value = + assertion.clientDataJson; + el("passkey-verify-auth-data").value = + assertion.authenticatorData; + el("passkey-verify-signature").value = assertion.signature; + addLog("Passkey Signed (real)", assertion); + return "Real assertion produced — verify fields filled. Now click Verify."; + }, +); + const passkeyAddRequestId = el("passkey-add-request-id"); +// Captured from the add-issue 202 so the retry can stamp the exact payload. +let passkeyAddPayloadToSign = ""; function buildPasskeyAddBody(): Record { return { type: "PASSKEY", @@ -917,6 +1095,7 @@ bindClick( addLog("PASSKEY Add (issue)", data); const d = data as Record; if (d.requestId) passkeyAddRequestId.value = d.requestId as string; + if (typeof d.payloadToSign === "string") passkeyAddPayloadToSign = d.payloadToSign; return JSON.stringify(data, null, 2); }, ); @@ -928,10 +1107,21 @@ bindClick( async () => { const requestId = passkeyAddRequestId.value.trim(); if (!requestId) throw new Error("Request-Id is required — run step 1 first."); + // Sandbox accepts the magic value, but real Turnkey requires the + // CREATE_AUTHENTICATORS payload to be stamped by an authorized credential — + // the active session's signing key. Establish a session (e.g. OTP login or + // passkey verify) first so the session signing key is available. + let signature = SANDBOX_SIG; + if (getMode() === "production") { + if (!passkeyAddPayloadToSign) { + throw new Error("Missing payloadToSign — run step 1 first."); + } + signature = await turnkeyStamp(passkeyAddPayloadToSign); + } const { data } = await apiPost( "/auth/credentials", buildPasskeyAddBody(), - { "Grid-Wallet-Signature": SANDBOX_SIG, "Request-Id": requestId }, + { "Grid-Wallet-Signature": signature, "Request-Id": requestId }, ); addLog("PASSKEY Add (retry)", data); return JSON.stringify(data, null, 2); diff --git a/apps/examples/grid-global-accounts-example-app/vite.config.ts b/apps/examples/grid-global-accounts-example-app/vite.config.ts index 7c5112695..e75698c77 100644 --- a/apps/examples/grid-global-accounts-example-app/vite.config.ts +++ b/apps/examples/grid-global-accounts-example-app/vite.config.ts @@ -1,10 +1,12 @@ import { defineConfig } from "vite"; import settings from "../settings.json"; -// Production 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"; +// Grid API base for the dev proxy (strips the `/api` prefix and rewrites the +// path to the versioned API channel). Defaults to production; override locally +// for a dev backend via the GRID_URL env var, e.g. +// GRID_URL=https://api.dev.dev.sparkinfra.net yarn dev +// Credentials are entered manually in the UI — never embedded here. +const PROD_GRID_URL = process.env.GRID_URL ?? "https://api.lightspark.com"; export default defineConfig({ server: { From d22cd61397154cd89b897dd61a9f8a22dd2fe1f8 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 12 Jun 2026 02:58:47 -0700 Subject: [PATCH 75/90] [js] gga example app: split main.ts into config/turnkey/webauthn/api-client/ui + flows modules (#28471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Split the ~1450-line `src/main.ts` into a small ES-module tree: `config.ts`, `turnkey.ts` (crypto), `webauthn.ts` (ceremonies), `api-client.ts`, `ui.ts`, and a `flows/` directory (`customer`, `email-otp`, `oauth`, `passkey`, `manage`, `money`, shared `context`). `main.ts` becomes a thin bootstrap. ## Why P4 example app, PR 1 in `40-example-app-design.md` §1.5/§5. The single file mixed Turnkey crypto, HTTP, logging, DOM wiring, and every flow handler, and accreted into a tool only its author could drive. Carving it into modules lands first so later PRs touch small files; the `manage.ts` extraction also removes the 3× delete/export duplication (one shared panel instead of one per credential type). ## Place in the stack Base: #28470 (real WebAuthn ceremony + env-driven URL). Second PR of the **P4 example-app** stack. ## Notable points - **Pure refactor, no behavior change** — mechanical module move; `index.html` untouched. `tsc` + manual sandbox smoke prove equivalence. - Manual test tool (no automated UI tests). Type gate: `build` + `lint`/`format`. --- Part of the Turnkey login-family migration program. See `sparkcore/sparkcore/grid/docs/login-migration/00-program-plan.md`. GitOrigin-RevId: fe1f4c2ca75d37fb07e37b9902241db970ce26d8 --- .../src/api-client.ts | 104 ++ .../src/config.ts | 15 + .../src/flows/context.ts | 61 + .../src/flows/customer.ts | 150 ++ .../src/flows/email-otp.ts | 188 +++ .../src/flows/manage.ts | 184 +++ .../src/flows/money.ts | 177 ++ .../src/flows/oauth.ts | 113 ++ .../src/flows/passkey.ts | 238 +++ .../src/main.ts | 1458 +---------------- .../src/turnkey.ts | 184 +++ .../src/ui.ts | 125 ++ .../src/webauthn.ts | 118 ++ 13 files changed, 1676 insertions(+), 1439 deletions(-) create mode 100644 apps/examples/grid-global-accounts-example-app/src/api-client.ts create mode 100644 apps/examples/grid-global-accounts-example-app/src/config.ts create mode 100644 apps/examples/grid-global-accounts-example-app/src/flows/context.ts create mode 100644 apps/examples/grid-global-accounts-example-app/src/flows/customer.ts create mode 100644 apps/examples/grid-global-accounts-example-app/src/flows/email-otp.ts create mode 100644 apps/examples/grid-global-accounts-example-app/src/flows/manage.ts create mode 100644 apps/examples/grid-global-accounts-example-app/src/flows/money.ts create mode 100644 apps/examples/grid-global-accounts-example-app/src/flows/oauth.ts create mode 100644 apps/examples/grid-global-accounts-example-app/src/flows/passkey.ts create mode 100644 apps/examples/grid-global-accounts-example-app/src/turnkey.ts create mode 100644 apps/examples/grid-global-accounts-example-app/src/ui.ts create mode 100644 apps/examples/grid-global-accounts-example-app/src/webauthn.ts diff --git a/apps/examples/grid-global-accounts-example-app/src/api-client.ts b/apps/examples/grid-global-accounts-example-app/src/api-client.ts new file mode 100644 index 000000000..23882a7a8 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/api-client.ts @@ -0,0 +1,104 @@ +// HTTP client + auth header + mode resolution. + +import { API_BASE, type Mode } from "./config"; +import { el } from "./ui"; + +let authClientId: HTMLInputElement | null = null; +let authClientSecret: HTMLInputElement | null = null; +let modeSelect: HTMLSelectElement | null = null; + +function getAuthClientId(): HTMLInputElement { + if (!authClientId) authClientId = el("auth-client-id"); + return authClientId; +} + +function getAuthClientSecret(): HTMLInputElement { + if (!authClientSecret) + authClientSecret = el("auth-client-secret"); + return authClientSecret; +} + +function getModeSelect(): HTMLSelectElement { + if (!modeSelect) modeSelect = el("mode-select"); + return modeSelect; +} + +export function getMode(): Mode { + return getModeSelect().value === "production" ? "production" : "sandbox"; +} + +function getAuthHeader(): string { + return ( + "Basic " + + btoa( + `${getAuthClientId().value.trim()}:${getAuthClientSecret().value.trim()}`, + ) + ); +} + +export 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 }; +} + +export 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 }; +} + +export async function apiPatch( + path: string, + body: Record, + 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 }; +} + +export 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; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/config.ts b/apps/examples/grid-global-accounts-example-app/src/config.ts new file mode 100644 index 000000000..e9726befd --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/config.ts @@ -0,0 +1,15 @@ +// Grid Global Accounts — Example App: shared config + constants. + +export type Mode = "sandbox" | "production"; +export type CredType = "email_otp" | "oauth" | "passkey"; + +// Sandbox magic signature injected into signed-retry headers and the execute +// signature. In production these are wrong — a real stamp must be supplied. +export const SANDBOX_SIG = "sandbox-valid-signature"; + +// All requests proxy through Vite at `/api` and forward to the configured Grid +// backend. Credentials are entered manually in the UI — never embedded. +export const API_BASE = "/api"; + +// Turnkey API stamp scheme — must match what `@turnkey/api-key-stamper` emits. +export const TURNKEY_STAMP_SCHEME = "SIGNATURE_SCHEME_TK_API_P256"; diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/context.ts b/apps/examples/grid-global-accounts-example-app/src/flows/context.ts new file mode 100644 index 000000000..96e6784ac --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/context.ts @@ -0,0 +1,61 @@ +// Cross-flow wallet context: account / credential / session ids shared between +// the per-credential-type tabs and the money + manage flows. + +import { el } from "../ui"; + +let ctxAccountId: HTMLInputElement | null = null; +let ctxCredentialId: HTMLInputElement | null = null; +let ctxSessionId: HTMLInputElement | null = null; + +function accountIdEl(): HTMLInputElement { + if (!ctxAccountId) ctxAccountId = el("ctx-account-id"); + return ctxAccountId; +} +function credentialIdEl(): HTMLInputElement { + if (!ctxCredentialId) + ctxCredentialId = el("ctx-credential-id"); + return ctxCredentialId; +} +function sessionIdEl(): HTMLInputElement { + if (!ctxSessionId) ctxSessionId = el("ctx-session-id"); + return ctxSessionId; +} + +// First-call-wins by design: the account id is established once (Create +// Customer) and shared across every credential-type tab, so a later per-type +// flow must not clobber it. Credential/session ids below are per-type and do +// overwrite. To switch accounts, clear the field in the UI. +export function setCtxAccount(id: string): void { + if (!accountIdEl().value) accountIdEl().value = id; +} +export function setCtxCredential(id: string): void { + credentialIdEl().value = id; +} +export function setCtxSession(id: string): void { + sessionIdEl().value = id; +} + +export function requireAccountId(): string { + const id = accountIdEl().value.trim(); + if (!id) + throw new Error( + "Internal Account ID is required — run Create Customer first.", + ); + return id; +} + +export function requireCredentialId(): string { + const id = credentialIdEl().value.trim(); + if (!id) + throw new Error( + "Credential ID is required — run Create for this type first.", + ); + return id; +} + +export function requireSessionId(): string { + const id = sessionIdEl().value.trim(); + if (!id) + throw new Error("Session ID is required — run Verify for this type first."); + return id; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/customer.ts b/apps/examples/grid-global-accounts-example-app/src/flows/customer.ts new file mode 100644 index 000000000..3714ed96e --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/customer.ts @@ -0,0 +1,150 @@ +// Shared setup: create customer, platform config (OTP + branding), balance. + +import { apiGet, apiPatch, apiPost } from "../api-client"; +import { addLog, bindClick, el, maybeEl } from "../ui"; +import { setCtxAccount } from "./context"; + +// ----- Create customer + Fetch balance ----- + +export function wireCustomerFlows(): void { + 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, + taxId: "12-3456789", + incorporatedOn: "2020-01-01", + }, + }; + 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}\nEmbedded wallet pre-created at customer-create time.`; + } + return `Customer: ${customerId}\nNo USDB account found yet — wallet provisioning may be in progress.`; + }, + ); + + 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, + ); + }, + ); + + wirePlatformConfigFlows(); +} + +// ----- Platform config (OTP + branding) — GET to populate, PATCH to save ----- + +function wirePlatformConfigFlows(): void { + 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."; + }); +} diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/email-otp.ts b/apps/examples/grid-global-accounts-example-app/src/flows/email-otp.ts new file mode 100644 index 000000000..fc2012e5b --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/email-otp.ts @@ -0,0 +1,188 @@ +// EMAIL_OTP lifecycle: create, secure-OTP challenge/verify, rechallenge, add. + +import { generateP256KeyPair } from "@turnkey/crypto"; + +import { SANDBOX_SIG } from "../config"; +import { apiPost } from "../api-client"; +import { + buildWalletSignature, + sealOtpBundle, + setSessionKeysFromTek, +} from "../turnkey"; +import { addLog, bindClick, el } from "../ui"; +import { + requireAccountId, + requireCredentialId, + setCtxCredential, + setCtxSession, +} from "./context"; + +export function wireEmailOtpFlows(): void { + 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); + }, + ); + + // Secure OTP — two steps so it works against real Turnkey, which emails a + // real OTP (sandbox uses the fixed 000000). Step 1 (/challenge) issues the + // INIT_OTP and returns the enclave's target bundle, held below until Verify + // consumes it. Step 2 + // HPKE-seals the entered code under that bundle, runs /verify first leg + // (202 + payloadToSign), signs the token with the TEK, and runs /verify retry + // (200 session). The code never leaves the client in plaintext; the TEK private + // key stays client-side (no encryptedSessionSigningKey is returned). + + // Target bundle from the most recent V3 challenge + the credential it was + // issued for, so Verify catches a stale/mismatched bundle. + let v3TargetBundle: string | null = null; + let v3TargetBundleCredId: string | null = null; + + bindClick( + "btn-email_otp-v3-challenge", + "email_otp-v3-challenge-status", + "EMAIL_OTP Challenge (V3)", + "Requesting OTP...", + async () => { + const credId = requireCredentialId(); + const { data: challengeData } = await apiPost( + `/auth/credentials/${encodeURIComponent(credId)}/challenge`, + {}, + ); + addLog("V3 Challenge", challengeData); + const targetBundle = (challengeData as Record) + .otpEncryptionTargetBundle as string | undefined; + if (!targetBundle) + throw new Error( + "Challenge response missing otpEncryptionTargetBundle — is the local " + + "backend running the secure-OTP branch?", + ); + v3TargetBundle = targetBundle; + v3TargetBundleCredId = credId; + return "OTP sent. Check the customer's email, enter the code below, then Verify."; + }, + ); + + bindClick( + "btn-email_otp-v3-verify", + "email_otp-v3-verify-status", + "EMAIL_OTP Verify (V3)", + "Verifying...", + async () => { + const credId = requireCredentialId(); + const otp = el("email_otp-v3-code").value.trim(); + if (!otp) throw new Error("OTP code is required."); + if (!v3TargetBundle || v3TargetBundleCredId !== credId) + throw new Error( + "Run Challenge (V3) first to request an OTP + target bundle for this " + + "credential.", + ); + + // Generate a TEK and HPKE-seal the entered OTP under the challenge bundle. + const tek = generateP256KeyPair(); + const encryptedOtpBundle = sealOtpBundle(v3TargetBundle, tek.publicKey, otp); + + // First leg → expect 202 with payloadToSign (verificationToken) + requestId. + const leg1 = await apiPost( + `/auth/credentials/${encodeURIComponent(credId)}/verify`, + { type: "EMAIL_OTP", encryptedOtpBundle }, + ); + const l1 = (leg1.data ?? {}) as Record; + addLog("V3 Verify leg 1 (expect 202)", { status: leg1.status, ...l1 }); + const payloadToSign = l1.payloadToSign as string | undefined; + const requestId = l1.requestId as string | undefined; + if (leg1.status !== 202 || !payloadToSign || !requestId) + throw new Error(`Unexpected first-leg response: ${JSON.stringify(leg1)}`); + + // Sign the verificationToken with the TEK private key. + const signature = await buildWalletSignature( + tek.publicKey, + tek.privateKey, + payloadToSign, + ); + + // Retry with the signature → expect 200 AuthSession. + const leg2 = await apiPost( + `/auth/credentials/${encodeURIComponent(credId)}/verify`, + { type: "EMAIL_OTP", encryptedOtpBundle }, + { "Grid-Wallet-Signature": signature, "Request-Id": requestId }, + ); + const session = (leg2.data ?? {}) as Record; + addLog("V3 Verify leg 2 (expect 200 session)", { + status: leg2.status, + ...session, + }); + if (session.id) setCtxSession(session.id as string); + // The TEK is now the session's API key (OTP_LOGIN registered it). Cache it + // as the active session signing key so later signed retries (add passkey, + // quote execute, etc.) can stamp with this session via turnkeyStamp(). + if (leg2.status === 200) setSessionKeysFromTek(tek); + // One bundle per challenge — force a fresh Challenge for the next run. + v3TargetBundle = null; + v3TargetBundleCredId = null; + return JSON.stringify({ leg1: leg1.data, session: leg2.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); + }, + ); +} diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/manage.ts b/apps/examples/grid-global-accounts-example-app/src/flows/manage.ts new file mode 100644 index 000000000..fbe2b66a7 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/manage.ts @@ -0,0 +1,184 @@ +// Shared signed-retry wiring per tab: delete credential / session / export, + +// list credentials / sessions. +// +// Endpoints are identical for all tabs — inputs come from the shared ctx, and +// the per-tab buttons just visually group each flow under the relevant tab. +// The three flows below are wired once per credential type in a single loop +// (`wireManageFlows`), replacing the previously inlined per-type duplication. + +import { CredType, SANDBOX_SIG } from "../config"; +import { apiDelete, apiGet, apiPost } from "../api-client"; +import { addLog, bindClick, maybeEl } from "../ui"; +import { + requireAccountId, + requireCredentialId, + requireSessionId, +} from "./context"; + +// Request-Id inputs are looked up lazily inside the handlers (via `maybeEl`) +// rather than captured eagerly with `el()` at wire time, matching `bindClick`'s +// graceful-skip pattern: a missing element degrades just that one button +// instead of throwing and aborting the rest of `wireManageFlows`. +function requestIdInput(id: string): HTMLInputElement | null { + return maybeEl(id); +} + +function wireDeleteCredentialButtons(type: CredType): void { + const reqInputId = `${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; + const reqInput = requestIdInput(reqInputId); + if (d.requestId && reqInput) 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 = requestIdInput(reqInputId)?.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 reqInputId = `${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; + const reqInput = requestIdInput(reqInputId); + if (d.requestId && reqInput) 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 = requestIdInput(reqInputId)?.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 reqInputId = `${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; + const reqInput = requestIdInput(reqInputId); + if (d.requestId && reqInput) 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 = requestIdInput(reqInputId)?.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); + }, + ); +} + +function wireListButtons(): void { + 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); + }, + ); +} + +export function wireManageFlows(): void { + for (const type of ["email_otp", "oauth", "passkey"] as const) { + wireDeleteCredentialButtons(type); + wireDeleteSessionButtons(type); + wireExportButtons(type); + } + wireListButtons(); +} diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/money.ts b/apps/examples/grid-global-accounts-example-app/src/flows/money.ts new file mode 100644 index 000000000..33e834b1a --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/money.ts @@ -0,0 +1,177 @@ +// Money movement: external account, quote, sign payload, execute. + +import { SANDBOX_SIG } from "../config"; +import { apiPost, getMode } from "../api-client"; +import { turnkeyStamp } from "../turnkey"; +import { addLog, bindClick, el } from "../ui"; +import { requireAccountId } from "./context"; + +export function wireMoneyFlows(): void { + 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"); + const executePayloadToSign = el( + "execute-payload-to-sign", + ); + const executeSignature = el("execute-signature"); + + 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); + }, + ); + + 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); + }, + ); +} diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/oauth.ts b/apps/examples/grid-global-accounts-example-app/src/flows/oauth.ts new file mode 100644 index 000000000..94ae08789 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/oauth.ts @@ -0,0 +1,113 @@ +// OAUTH lifecycle: create, verify (→ session), rechallenge (no-op), add. + +import { SANDBOX_SIG } from "../config"; +import { apiPost } from "../api-client"; +import { rememberEncryptedSessionSigningKey } from "../turnkey"; +import { addLog, bindClick, el, wireGenKeyButton } from "../ui"; +import { + requireAccountId, + requireCredentialId, + setCtxCredential, + setCtxSession, +} from "./context"; + +export function wireOauthFlows(): void { + 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); + }, + ); +} diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/passkey.ts b/apps/examples/grid-global-accounts-example-app/src/flows/passkey.ts new file mode 100644 index 000000000..069001b5c --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/passkey.ts @@ -0,0 +1,238 @@ +// PASSKEY lifecycle: create (real registration), challenge, verify (assertion), +// add (signed retry, session-stamped in production). + +import { SANDBOX_SIG } from "../config"; +import { apiPost, getMode } from "../api-client"; +import { + rememberEncryptedSessionSigningKey, + turnkeyStamp, +} from "../turnkey"; +import { createRealPasskey, signWithPasskey } from "../webauthn"; +import { addLog, bindClick, el, wireGenKeyButton } from "../ui"; +import { + requireAccountId, + requireCredentialId, + setCtxCredential, + setCtxSession, +} from "./context"; + +export function wirePasskeyFlows(): void { + 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); + }, + ); + + // Drive a real WebAuthn registration (Touch ID) and fill the attestation + // fields above — used by both the "Create" and "Add additional" flows. + bindClick( + "btn-passkey-webauthn-create", + "passkey-webauthn-create-status", + "Passkey Register", + "Waiting for authenticator (Touch ID)...", + async () => { + const nickname = el( + "passkey-create-nickname", + ).value.trim(); + const att = await createRealPasskey(nickname); + el("passkey-create-challenge").value = att.challenge; + el("passkey-create-cred-id-raw").value = + att.credentialId; + el("passkey-create-client-data-json").value = + att.clientDataJson; + el("passkey-create-attestation-object").value = + att.attestationObject; + addLog("Passkey Registered (real)", att); + return "Real passkey created — attestation fields filled. Now run Create or Add."; + }, + ); + + wireGenKeyButton("btn-passkey-challenge-genkey", "passkey-challenge-pubkey"); + const passkeyVerifyRequestId = el( + "passkey-verify-request-id", + ); + // Captured from the session-challenge response so the real assertion ceremony + // can sign the exact sha256-hex challenge Turnkey expects. + let passkeySessionChallenge = ""; + 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; + if (typeof d.challenge === "string") passkeySessionChallenge = d.challenge; + 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); + }, + ); + + // Drive a real WebAuthn assertion (Touch ID) against the issued challenge and + // fill the assertion fields above for Verify. + bindClick( + "btn-passkey-webauthn-get", + "passkey-webauthn-get-status", + "Passkey Sign", + "Waiting for authenticator (Touch ID)...", + async () => { + const credId = el( + "passkey-create-cred-id-raw", + ).value.trim(); + const assertion = await signWithPasskey(passkeySessionChallenge, credId); + el("passkey-create-cred-id-raw").value = + assertion.credentialId; + el("passkey-verify-client-data-json").value = + assertion.clientDataJson; + el("passkey-verify-auth-data").value = + assertion.authenticatorData; + el("passkey-verify-signature").value = + assertion.signature; + addLog("Passkey Signed (real)", assertion); + return "Real assertion produced — verify fields filled. Now click Verify."; + }, + ); + + const passkeyAddRequestId = el("passkey-add-request-id"); + // Captured from the add-issue 202 so the retry can stamp the exact payload. + let passkeyAddPayloadToSign = ""; + 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; + if (typeof d.payloadToSign === "string") + passkeyAddPayloadToSign = d.payloadToSign; + 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."); + // Sandbox accepts the magic value, but real Turnkey requires the + // CREATE_AUTHENTICATORS payload to be stamped by an authorized credential — + // the active session's signing key. Establish a session (e.g. OTP login or + // passkey verify) first so the session signing key is available. + let signature = SANDBOX_SIG; + if (getMode() === "production") { + if (!passkeyAddPayloadToSign) { + throw new Error("Missing payloadToSign — run step 1 first."); + } + signature = await turnkeyStamp(passkeyAddPayloadToSign); + } + const { data } = await apiPost("/auth/credentials", buildPasskeyAddBody(), { + "Grid-Wallet-Signature": signature, + "Request-Id": requestId, + }); + addLog("PASSKEY Add (retry)", data); + return JSON.stringify(data, null, 2); + }, + ); +} diff --git a/apps/examples/grid-global-accounts-example-app/src/main.ts b/apps/examples/grid-global-accounts-example-app/src/main.ts index 18ac8f80e..cd956c442 100644 --- a/apps/examples/grid-global-accounts-example-app/src/main.ts +++ b/apps/examples/grid-global-accounts-example-app/src/main.ts @@ -3,1445 +3,25 @@ // 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, - formatHpkeBuf, - generateP256KeyPair, - getPublicKey, - hpkeEncrypt, -} 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; - } -} - -// OTP_LOGIN / STAMP_LOGIN model: there is no encryptedSessionSigningKey bundle -// — the TEK private key *is* the session's API key once login registers it. -// Cache it directly so turnkeyStamp() can authorize later signed retries -// (e.g. adding a passkey) without the Verify-style clientKeyPair + bundle. -function setSessionKeysFromTek(tek: { - publicKey: string; - privateKey: string; -}): void { - cachedSessionKeys = { - apiPublicKey: tek.publicKey, - apiPrivateKey: tek.privateKey, - }; -} - -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(/=+$/, ""); -} - -// ----- V3 secure OTP client crypto ----- +// (forwards with `Grid-Wallet-Signature`). // -// HPKE-seal {clientPublicKey, otpCodeAttempt} under the enclave's -// `otpEncryptionTargetBundle`. That bundle is a signed enclave envelope — -// {version, data, dataSignature, enclaveQuorumPublic} — where `data` is a -// hex-encoded JSON blob carrying the enclave's uncompressed HPKE target key as -// `targetPublic`. We pull `targetPublic` out, HPKE-encrypt under it, and emit -// Turnkey's `formatHpkeBuf` wire shape {"encappedPublic","ciphertext"} — exactly -// what `@turnkey/crypto`'s `encryptPrivateKeyToBundle` produces for the -// analogous key-import flow. (A production client would also verify -// `dataSignature` against `enclaveQuorumPublic`; skipped here because the bundle -// originates from our own backend in this test app.) -function hexToBytes(hex: string): Uint8Array { - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < bytes.length; i++) { - bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); - } - return bytes; -} - -function sealOtpBundle( - targetBundle: string, - clientPublicKeyHex: string, - otpCode: string, -): string { - const parsed = JSON.parse(targetBundle) as { data: string }; - const signedData = JSON.parse( - new TextDecoder().decode(hexToBytes(parsed.data)), - ) as { targetPublic: string }; - const targetKeyBuf = hexToBytes(signedData.targetPublic); // 65-byte uncompressed - const plainTextBuf = new TextEncoder().encode( - // The enclave expects snake_case {otp_code, public_key} — NOT the - // {clientPublicKey, otpCodeAttempt} shown in Turnkey's docs sequence - // diagram. Matches @turnkey/crypto's encryptOtpCodeToBundle. - JSON.stringify({ otp_code: otpCode, public_key: clientPublicKeyHex }), - ); - const encryptedBuf = hpkeEncrypt({ plainTextBuf, targetKeyBuf }); // compressed_enc[33] || ciphertext - return formatHpkeBuf(encryptedBuf); // {"encappedPublic","ciphertext"} -} - -// Build the `Grid-Wallet-Signature` stamp over the verificationToken using a -// specific keypair (the V3 TEK), not the session key — base64url(JSON({ -// publicKey, scheme, signature})), the shape `parse_api_key_stamp` expects. -async function buildWalletSignature( - publicKeyHex: string, - privateKeyHex: string, - payload: string, -): Promise { - const signature = await signWithApiKey({ - content: payload, - publicKey: publicKeyHex, - privateKey: privateKeyHex, - }); - const stamp = { - publicKey: publicKeyHex, - scheme: TURNKEY_STAMP_SCHEME, - signature, - }; - return btoa(JSON.stringify(stamp)) - .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 apiPatch( - path: string, - body: Record, - 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() }, - }); - 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, - taxId: "12-3456789", - incorporatedOn: "2020-01-01", - }, - }; - 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}\nEmbedded wallet pre-created at customer-create time.`; - } - 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", - "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); - }, -); - -// Secure OTP — two steps so it works against real Turnkey, which emails a -// real OTP (sandbox uses the fixed 000000). Step 1 (/challenge) issues the -// INIT_OTP and returns the enclave's target bundle, held below until Verify -// consumes it. Step 2 -// HPKE-seals the entered code under that bundle, runs /verify first leg -// (202 + payloadToSign), signs the token with the TEK, and runs /verify retry -// (200 session). The code never leaves the client in plaintext; the TEK private -// key stays client-side (no encryptedSessionSigningKey is returned). - -// Target bundle from the most recent V3 challenge + the credential it was -// issued for, so Verify catches a stale/mismatched bundle. -let v3TargetBundle: string | null = null; -let v3TargetBundleCredId: string | null = null; - -bindClick( - "btn-email_otp-v3-challenge", - "email_otp-v3-challenge-status", - "EMAIL_OTP Challenge (V3)", - "Requesting OTP...", - async () => { - const credId = requireCredentialId(); - const { data: challengeData } = await apiPost( - `/auth/credentials/${encodeURIComponent(credId)}/challenge`, - {}, - ); - addLog("V3 Challenge", challengeData); - const targetBundle = (challengeData as Record) - .otpEncryptionTargetBundle as string | undefined; - if (!targetBundle) - throw new Error( - "Challenge response missing otpEncryptionTargetBundle — is the local " + - "backend running the secure-OTP branch?", - ); - v3TargetBundle = targetBundle; - v3TargetBundleCredId = credId; - return "OTP sent. Check the customer's email, enter the code below, then Verify."; - }, -); - -bindClick( - "btn-email_otp-v3-verify", - "email_otp-v3-verify-status", - "EMAIL_OTP Verify (V3)", - "Verifying...", - async () => { - const credId = requireCredentialId(); - const otp = el("email_otp-v3-code").value.trim(); - if (!otp) throw new Error("OTP code is required."); - if (!v3TargetBundle || v3TargetBundleCredId !== credId) - throw new Error( - "Run Challenge (V3) first to request an OTP + target bundle for this " + - "credential.", - ); - - // Generate a TEK and HPKE-seal the entered OTP under the challenge bundle. - const tek = generateP256KeyPair(); - const encryptedOtpBundle = sealOtpBundle(v3TargetBundle, tek.publicKey, otp); - - // First leg → expect 202 with payloadToSign (verificationToken) + requestId. - const leg1 = await apiPost( - `/auth/credentials/${encodeURIComponent(credId)}/verify`, - { type: "EMAIL_OTP", encryptedOtpBundle }, - ); - const l1 = (leg1.data ?? {}) as Record; - addLog("V3 Verify leg 1 (expect 202)", { status: leg1.status, ...l1 }); - const payloadToSign = l1.payloadToSign as string | undefined; - const requestId = l1.requestId as string | undefined; - if (leg1.status !== 202 || !payloadToSign || !requestId) - throw new Error(`Unexpected first-leg response: ${JSON.stringify(leg1)}`); - - // Sign the verificationToken with the TEK private key. - const signature = await buildWalletSignature( - tek.publicKey, - tek.privateKey, - payloadToSign, - ); - - // Retry with the signature → expect 200 AuthSession. - const leg2 = await apiPost( - `/auth/credentials/${encodeURIComponent(credId)}/verify`, - { type: "EMAIL_OTP", encryptedOtpBundle }, - { "Grid-Wallet-Signature": signature, "Request-Id": requestId }, - ); - const session = (leg2.data ?? {}) as Record; - addLog("V3 Verify leg 2 (expect 200 session)", { - status: leg2.status, - ...session, - }); - if (session.id) setCtxSession(session.id as string); - // The TEK is now the session's API key (OTP_LOGIN registered it). Cache it - // as the active session signing key so later signed retries (add passkey, - // quote execute, etc.) can stamp with this session via turnkeyStamp(). - if (leg2.status === 200) setSessionKeysFromTek(tek); - // One bundle per challenge — force a fresh Challenge for the next run. - v3TargetBundle = null; - v3TargetBundleCredId = null; - return JSON.stringify({ leg1: leg1.data, session: leg2.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); - }, -); - -// ----- WebAuthn ceremony helpers (real passkeys) ----- -// -// The sandbox flows accept magic placeholder strings, but a real Turnkey -// sub-org needs a genuine WebAuthn credential. These helpers drive the -// browser's authenticator (Touch ID, etc.) and base64url-encode the results -// into the same fields the sandbox flow uses, so Create / Add / Verify work -// unchanged against production Turnkey. -// -// NOTE: WebAuthn binds a credential to an RP ID that must be a suffix of the -// page origin — on localhost that means rpId="localhost". The Turnkey sub-org -// must have been created with the SAME RP ID or verification will fail. - -function bytesToB64Url(bytes: Uint8Array): string { - let bin = ""; - for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); - return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -function b64UrlToBytes(value: string): Uint8Array { - const b64 = value.replace(/-/g, "+").replace(/_/g, "/"); - const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); - const bin = atob(padded); - const bytes = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); - return bytes; -} - -function passkeyRpId(): string { - return el("passkey-rp-id").value.trim() || location.hostname; -} - -interface RealAttestation { - challenge: string; - credentialId: string; - clientDataJson: string; - attestationObject: string; -} - -// Real registration ceremony — produces the attestation that Create/Add send. -async function createRealPasskey(nickname: string): Promise { - const challenge = crypto.getRandomValues(new Uint8Array(32)); - const userId = crypto.getRandomValues(new Uint8Array(16)); - const credential = (await navigator.credentials.create({ - publicKey: { - rp: { id: passkeyRpId(), name: "Grid Example App" }, - user: { - id: userId, - name: nickname || "grid-example-user", - displayName: nickname || "Grid Example User", - }, - challenge, - pubKeyCredParams: [ - { type: "public-key", alg: -7 }, - { type: "public-key", alg: -257 }, - ], - authenticatorSelection: { - residentKey: "preferred", - userVerification: "preferred", - }, - attestation: "none", - timeout: 60000, - }, - })) as PublicKeyCredential | null; - if (!credential) throw new Error("Passkey creation returned no credential"); - const response = credential.response as AuthenticatorAttestationResponse; - return { - challenge: bytesToB64Url(challenge), - credentialId: bytesToB64Url(new Uint8Array(credential.rawId)), - clientDataJson: bytesToB64Url(new Uint8Array(response.clientDataJSON)), - attestationObject: bytesToB64Url(new Uint8Array(response.attestationObject)), - }; -} - -interface RealAssertion { - credentialId: string; - authenticatorData: string; - clientDataJson: string; - signature: string; -} - -// Real assertion ceremony — signs the issued session challenge. -async function signWithPasskey( - challengeValue: string, - credentialId: string, -): Promise { - if (!challengeValue) { - throw new Error("No challenge — issue a session challenge (step above) first."); - } - // PR #28427: Turnkey's WebAuthn challenge is the UTF-8 bytes of the - // sha256-hex challenge string returned by /challenge — NOT base64url-decoded. - const challenge = new TextEncoder().encode(challengeValue); - const allowCredentials: PublicKeyCredentialDescriptor[] = credentialId - ? [{ type: "public-key", id: b64UrlToBytes(credentialId) as BufferSource }] - : []; - const credential = (await navigator.credentials.get({ - publicKey: { - rpId: passkeyRpId(), - challenge, - allowCredentials, - userVerification: "preferred", - timeout: 60000, - }, - })) as PublicKeyCredential | null; - if (!credential) throw new Error("Passkey assertion returned no credential"); - const response = credential.response as AuthenticatorAssertionResponse; - return { - credentialId: bytesToB64Url(new Uint8Array(credential.rawId)), - authenticatorData: bytesToB64Url(new Uint8Array(response.authenticatorData)), - clientDataJson: bytesToB64Url(new Uint8Array(response.clientDataJSON)), - signature: bytesToB64Url(new Uint8Array(response.signature)), - }; -} - -// ----- 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); - }, -); - -// Drive a real WebAuthn registration (Touch ID) and fill the attestation -// fields above — used by both the "Create" and "Add additional" flows. -bindClick( - "btn-passkey-webauthn-create", - "passkey-webauthn-create-status", - "Passkey Register", - "Waiting for authenticator (Touch ID)...", - async () => { - const nickname = el("passkey-create-nickname").value.trim(); - const att = await createRealPasskey(nickname); - el("passkey-create-challenge").value = att.challenge; - el("passkey-create-cred-id-raw").value = att.credentialId; - el("passkey-create-client-data-json").value = att.clientDataJson; - el("passkey-create-attestation-object").value = - att.attestationObject; - addLog("Passkey Registered (real)", att); - return "Real passkey created — attestation fields filled. Now run Create or Add."; - }, -); - -wireGenKeyButton("btn-passkey-challenge-genkey", "passkey-challenge-pubkey"); -const passkeyVerifyRequestId = el("passkey-verify-request-id"); -// Captured from the session-challenge response so the real assertion ceremony -// can sign the exact sha256-hex challenge Turnkey expects. -let passkeySessionChallenge = ""; -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; - if (typeof d.challenge === "string") passkeySessionChallenge = d.challenge; - 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); - }, -); - -// Drive a real WebAuthn assertion (Touch ID) against the issued challenge and -// fill the assertion fields above for Verify. -bindClick( - "btn-passkey-webauthn-get", - "passkey-webauthn-get-status", - "Passkey Sign", - "Waiting for authenticator (Touch ID)...", - async () => { - const credId = el("passkey-create-cred-id-raw").value.trim(); - const assertion = await signWithPasskey(passkeySessionChallenge, credId); - el("passkey-create-cred-id-raw").value = assertion.credentialId; - el("passkey-verify-client-data-json").value = - assertion.clientDataJson; - el("passkey-verify-auth-data").value = - assertion.authenticatorData; - el("passkey-verify-signature").value = assertion.signature; - addLog("Passkey Signed (real)", assertion); - return "Real assertion produced — verify fields filled. Now click Verify."; - }, -); - -const passkeyAddRequestId = el("passkey-add-request-id"); -// Captured from the add-issue 202 so the retry can stamp the exact payload. -let passkeyAddPayloadToSign = ""; -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; - if (typeof d.payloadToSign === "string") passkeyAddPayloadToSign = d.payloadToSign; - 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."); - // Sandbox accepts the magic value, but real Turnkey requires the - // CREATE_AUTHENTICATORS payload to be stamped by an authorized credential — - // the active session's signing key. Establish a session (e.g. OTP login or - // passkey verify) first so the session signing key is available. - let signature = SANDBOX_SIG; - if (getMode() === "production") { - if (!passkeyAddPayloadToSign) { - throw new Error("Missing payloadToSign — run step 1 first."); - } - signature = await turnkeyStamp(passkeyAddPayloadToSign); - } - const { data } = await apiPost( - "/auth/credentials", - buildPasskeyAddBody(), - { "Grid-Wallet-Signature": signature, "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); - }, -); +// Thin bootstrap: wire tabs, then each flow module. Behavior lives in the +// `flows/` tree + the `config / turnkey / webauthn / api-client / ui` modules. + +import { wireTabs } from "./ui"; +import { wireCustomerFlows } from "./flows/customer"; +import { wireEmailOtpFlows } from "./flows/email-otp"; +import { wireOauthFlows } from "./flows/oauth"; +import { wirePasskeyFlows } from "./flows/passkey"; +import { wireManageFlows } from "./flows/manage"; +import { wireMoneyFlows } from "./flows/money"; + +wireTabs(); +wireCustomerFlows(); +wireEmailOtpFlows(); +wireOauthFlows(); +wirePasskeyFlows(); +wireManageFlows(); +wireMoneyFlows(); console.log("Grid Global Accounts example app loaded."); diff --git a/apps/examples/grid-global-accounts-example-app/src/turnkey.ts b/apps/examples/grid-global-accounts-example-app/src/turnkey.ts new file mode 100644 index 000000000..060938e9d --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/turnkey.ts @@ -0,0 +1,184 @@ +// Turnkey crypto: P-256 keygen, HPKE seal, wallet signature, session-key state, +// and the X-Stamp builder. + +import { + decryptCredentialBundle, + formatHpkeBuf, + generateP256KeyPair, + getPublicKey, + hpkeEncrypt, +} from "@turnkey/crypto"; +import { signWithApiKey } from "@turnkey/api-key-stamper"; + +import { TURNKEY_STAMP_SCHEME } from "./config"; + +// ----- 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. + +export interface ClientKeyPair { + privateKey: string; // hex + publicKey: string; // hex, compressed + publicKeyUncompressed: string; // hex, 130 chars (0x04 prefix) +} + +export 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; + +export 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; +} + +export function rememberEncryptedSessionSigningKey(value: unknown): void { + if (typeof value === "string" && value) { + lastEncryptedSessionSigningKey = value; + cachedSessionKeys = null; + } +} + +// OTP_LOGIN / STAMP_LOGIN model: there is no encryptedSessionSigningKey bundle +// — the TEK private key *is* the session's API key once login registers it. +// Cache it directly so turnkeyStamp() can authorize later signed retries +// (e.g. adding a passkey) without the Verify-style clientKeyPair + bundle. +export function setSessionKeysFromTek(tek: { + publicKey: string; + privateKey: string; +}): void { + cachedSessionKeys = { + apiPublicKey: tek.publicKey, + apiPrivateKey: tek.privateKey, + }; +} + +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; +} + +export 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(/=+$/, ""); +} + +// ----- V3 secure OTP client crypto ----- +// +// HPKE-seal {clientPublicKey, otpCodeAttempt} under the enclave's +// `otpEncryptionTargetBundle`. That bundle is a signed enclave envelope — +// {version, data, dataSignature, enclaveQuorumPublic} — where `data` is a +// hex-encoded JSON blob carrying the enclave's uncompressed HPKE target key as +// `targetPublic`. We pull `targetPublic` out, HPKE-encrypt under it, and emit +// Turnkey's `formatHpkeBuf` wire shape {"encappedPublic","ciphertext"} — exactly +// what `@turnkey/crypto`'s `encryptPrivateKeyToBundle` produces for the +// analogous key-import flow. (A production client would also verify +// `dataSignature` against `enclaveQuorumPublic`; skipped here because the bundle +// originates from our own backend in this test app.) +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +export function sealOtpBundle( + targetBundle: string, + clientPublicKeyHex: string, + otpCode: string, +): string { + const parsed = JSON.parse(targetBundle) as { data: string }; + const signedData = JSON.parse( + new TextDecoder().decode(hexToBytes(parsed.data)), + ) as { targetPublic: string }; + const targetKeyBuf = hexToBytes(signedData.targetPublic); // 65-byte uncompressed + const plainTextBuf = new TextEncoder().encode( + // The enclave expects snake_case {otp_code, public_key} — NOT the + // {clientPublicKey, otpCodeAttempt} shown in Turnkey's docs sequence + // diagram. Matches @turnkey/crypto's encryptOtpCodeToBundle. + JSON.stringify({ otp_code: otpCode, public_key: clientPublicKeyHex }), + ); + const encryptedBuf = hpkeEncrypt({ plainTextBuf, targetKeyBuf }); // compressed_enc[33] || ciphertext + return formatHpkeBuf(encryptedBuf); // {"encappedPublic","ciphertext"} +} + +// Build the `Grid-Wallet-Signature` stamp over the verificationToken using a +// specific keypair (the V3 TEK), not the session key — base64url(JSON({ +// publicKey, scheme, signature})), the shape `parse_api_key_stamp` expects. +export async function buildWalletSignature( + publicKeyHex: string, + privateKeyHex: string, + payload: string, +): Promise { + const signature = await signWithApiKey({ + content: payload, + publicKey: publicKeyHex, + privateKey: privateKeyHex, + }); + const stamp = { + publicKey: publicKeyHex, + scheme: TURNKEY_STAMP_SCHEME, + signature, + }; + return btoa(JSON.stringify(stamp)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} diff --git a/apps/examples/grid-global-accounts-example-app/src/ui.ts b/apps/examples/grid-global-accounts-example-app/src/ui.ts new file mode 100644 index 000000000..ad103e97d --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/ui.ts @@ -0,0 +1,125 @@ +// DOM + logging + click-binding helpers. + +import { generateClientKeyPair } from "./turnkey"; + +// ----- DOM helpers ----- + +export function el(id: string): T { + const found = document.getElementById(id); + if (!found) throw new Error(`Missing element #${id}`); + return found as T; +} + +export function maybeEl(id: string): T | null { + return document.getElementById(id) as T | null; +} + +// ----- Logging ----- + +let logContainer: HTMLDivElement | null = null; + +function getLogContainer(): HTMLDivElement { + if (!logContainer) logContainer = el("log"); + return logContainer; +} + +function timestamp(): string { + return new Date().toISOString().replace("T", " ").slice(0, 19); +} + +export 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); + getLogContainer().prepend(entry); +} + +export function showStatus( + statusEl: HTMLDivElement, + ok: boolean, + text: string, +): void { + statusEl.className = `status ${ok ? "ok" : "err"}`; + statusEl.textContent = text; +} + +// ----- Generic click wrapper ----- + +export 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. + +export 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 ----- + +export function wireTabs(): void { + 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)); + }); + } +} diff --git a/apps/examples/grid-global-accounts-example-app/src/webauthn.ts b/apps/examples/grid-global-accounts-example-app/src/webauthn.ts new file mode 100644 index 000000000..d694e3495 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/webauthn.ts @@ -0,0 +1,118 @@ +// WebAuthn ceremony helpers (real passkeys). +// +// The sandbox flows accept magic placeholder strings, but a real Turnkey +// sub-org needs a genuine WebAuthn credential. These helpers drive the +// browser's authenticator (Touch ID, etc.) and base64url-encode the results +// into the same fields the sandbox flow uses, so Create / Add / Verify work +// unchanged against production Turnkey. +// +// NOTE: WebAuthn binds a credential to an RP ID that must be a suffix of the +// page origin — on localhost that means rpId="localhost". The Turnkey sub-org +// must have been created with the SAME RP ID or verification will fail. + +import { el } from "./ui"; + +export function bytesToB64Url(bytes: Uint8Array): string { + let bin = ""; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export function b64UrlToBytes(value: string): Uint8Array { + const b64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); + const bin = atob(padded); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} + +export function passkeyRpId(): string { + return el("passkey-rp-id").value.trim() || location.hostname; +} + +export interface RealAttestation { + challenge: string; + credentialId: string; + clientDataJson: string; + attestationObject: string; +} + +// Real registration ceremony — produces the attestation that Create/Add send. +export async function createRealPasskey( + nickname: string, +): Promise { + const challenge = crypto.getRandomValues(new Uint8Array(32)); + const userId = crypto.getRandomValues(new Uint8Array(16)); + const credential = (await navigator.credentials.create({ + publicKey: { + rp: { id: passkeyRpId(), name: "Grid Example App" }, + user: { + id: userId, + name: nickname || "grid-example-user", + displayName: nickname || "Grid Example User", + }, + challenge, + pubKeyCredParams: [ + { type: "public-key", alg: -7 }, + { type: "public-key", alg: -257 }, + ], + authenticatorSelection: { + residentKey: "preferred", + userVerification: "preferred", + }, + attestation: "none", + timeout: 60000, + }, + })) as PublicKeyCredential | null; + if (!credential) throw new Error("Passkey creation returned no credential"); + const response = credential.response as AuthenticatorAttestationResponse; + return { + challenge: bytesToB64Url(challenge), + credentialId: bytesToB64Url(new Uint8Array(credential.rawId)), + clientDataJson: bytesToB64Url(new Uint8Array(response.clientDataJSON)), + attestationObject: bytesToB64Url(new Uint8Array(response.attestationObject)), + }; +} + +export interface RealAssertion { + credentialId: string; + authenticatorData: string; + clientDataJson: string; + signature: string; +} + +// Real assertion ceremony — signs the issued session challenge. +export async function signWithPasskey( + challengeValue: string, + credentialId: string, +): Promise { + if (!challengeValue) { + throw new Error( + "No challenge — issue a session challenge (step above) first.", + ); + } + // PR #28427: Turnkey's WebAuthn challenge is the UTF-8 bytes of the + // sha256-hex challenge string returned by /challenge — NOT base64url-decoded. + const challenge = new TextEncoder().encode(challengeValue); + const allowCredentials: PublicKeyCredentialDescriptor[] = credentialId + ? [{ type: "public-key", id: b64UrlToBytes(credentialId) as BufferSource }] + : []; + const credential = (await navigator.credentials.get({ + publicKey: { + rpId: passkeyRpId(), + challenge, + allowCredentials, + userVerification: "preferred", + timeout: 60000, + }, + })) as PublicKeyCredential | null; + if (!credential) throw new Error("Passkey assertion returned no credential"); + const response = credential.response as AuthenticatorAssertionResponse; + return { + credentialId: bytesToB64Url(new Uint8Array(credential.rawId)), + authenticatorData: bytesToB64Url(new Uint8Array(response.authenticatorData)), + clientDataJson: bytesToB64Url(new Uint8Array(response.clientDataJSON)), + signature: bytesToB64Url(new Uint8Array(response.signature)), + }; +} From 42dc5e34fb7611ed38cc89cc46d07d648b5b0fab Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 12 Jun 2026 03:05:48 -0700 Subject: [PATCH 76/90] [js] gga example app: session.ts + status chip; disable-with-tooltip add-passkey (#28472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Add `session.ts` — one object that holds the client keypair / encrypted bundle / TEK and exposes "do we have a signing key?" + a model badge — and render a **session status chip** (account id, credential id, session id, signing-key ready/none, model). Make the add-passkey button **disabled-with-tooltip** ("log in first") when there's no session. ## Why P4 example app, PR 2 in `40-example-app-design.md` §1.1/§1.5/§5. The app had two invisible session models (Verify-bundle vs OTP-TEK) funneling through one `cachedSessionKeys` global, producing the "No client keypair — run a Verify first" trap: a stamp after an OTP login threw even though you *had* logged in. Centralizing session state into one object and surfacing it in a chip kills the trap by showing state instead of throwing on use, and replaces the runtime throw on add-passkey with a disabled button + tooltip. ## Place in the stack Base: #28471 (module split). Third PR of the **P4 example-app** stack. ## Notable points - Anticipates the login-family migration: `session.ts` makes both models explicit so the future flip (passkey/oauth converge on the OTP client-key-is-session model) is a one-line change per flow. `// MIGRATION:` breadcrumbs mark the exact switch points. - Manual test tool; type gate: `build` + `lint`/`format`. --- Part of the Turnkey login-family migration program. See `sparkcore/sparkcore/grid/docs/login-migration/00-program-plan.md`. GitOrigin-RevId: d6ed5ebc66d4ba72991861f6d915998567854d9b --- .../index.html | 27 ++- .../src/flows/context.ts | 56 ++--- .../src/flows/email-otp.ts | 9 +- .../src/flows/oauth.ts | 7 +- .../src/flows/passkey.ts | 36 ++- .../src/main.ts | 5 + .../src/session.ts | 227 ++++++++++++++++++ .../src/turnkey.ts | 93 ++----- .../src/ui.ts | 24 ++ 9 files changed, 361 insertions(+), 123 deletions(-) create mode 100644 apps/examples/grid-global-accounts-example-app/src/session.ts diff --git a/apps/examples/grid-global-accounts-example-app/index.html b/apps/examples/grid-global-accounts-example-app/index.html index 1ecb6c84c..d81b39e56 100644 --- a/apps/examples/grid-global-accounts-example-app/index.html +++ b/apps/examples/grid-global-accounts-example-app/index.html @@ -207,6 +207,29 @@ .tab-panel.active { display: block; } + .session-chip { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + margin-top: 8px; + padding: 8px 10px; + background: #0f3460; + border: 1px solid #2e3a5f; + border-radius: 4px; + font-size: 11px; + } + .chip-field { + white-space: nowrap; + } + .chip-key { + color: #888; + } + .chip-field.chip-ok { + color: #95d5b2; + } + .chip-field.chip-none { + color: #ff6b6b; + } @@ -346,8 +369,10 @@

Customer Setup

Wallet Context

Internal account id flows into every tab. Credential + session ids are - auto-filled as you run steps. + auto-filled as you run steps. The chip shows whether a session signing + key is ready and which model established it (OTP-TEK vs Verify-bundle).

+
("ctx-account-id"); - return ctxAccountId; -} -function credentialIdEl(): HTMLInputElement { - if (!ctxCredentialId) - ctxCredentialId = el("ctx-credential-id"); - return ctxCredentialId; -} -function sessionIdEl(): HTMLInputElement { - if (!ctxSessionId) ctxSessionId = el("ctx-session-id"); - return ctxSessionId; -} - -// First-call-wins by design: the account id is established once (Create -// Customer) and shared across every credential-type tab, so a later per-type -// flow must not clobber it. Credential/session ids below are per-type and do -// overwrite. To switch accounts, clear the field in the UI. -export function setCtxAccount(id: string): void { - if (!accountIdEl().value) accountIdEl().value = id; -} -export function setCtxCredential(id: string): void { - credentialIdEl().value = id; -} -export function setCtxSession(id: string): void { - sessionIdEl().value = id; -} +// Thin re-exports of the session setters so flows keep their familiar names. +// Note: `setCtxAccount`/`setAccountId` is first-call-wins by design (see +// session.ts) — the account id is established once and shared across tabs. +export const setCtxAccount = setAccountId; +export const setCtxCredential = setCredentialId; +export const setCtxSession = setSessionId; export function requireAccountId(): string { - const id = accountIdEl().value.trim(); + const id = getAccountId(); if (!id) throw new Error( "Internal Account ID is required — run Create Customer first.", @@ -45,7 +31,7 @@ export function requireAccountId(): string { } export function requireCredentialId(): string { - const id = credentialIdEl().value.trim(); + const id = getCredentialId(); if (!id) throw new Error( "Credential ID is required — run Create for this type first.", @@ -54,7 +40,7 @@ export function requireCredentialId(): string { } export function requireSessionId(): string { - const id = sessionIdEl().value.trim(); + const id = getSessionId(); if (!id) throw new Error("Session ID is required — run Verify for this type first."); return id; diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/email-otp.ts b/apps/examples/grid-global-accounts-example-app/src/flows/email-otp.ts index fc2012e5b..10b803fa2 100644 --- a/apps/examples/grid-global-accounts-example-app/src/flows/email-otp.ts +++ b/apps/examples/grid-global-accounts-example-app/src/flows/email-otp.ts @@ -4,11 +4,8 @@ import { generateP256KeyPair } from "@turnkey/crypto"; import { SANDBOX_SIG } from "../config"; import { apiPost } from "../api-client"; -import { - buildWalletSignature, - sealOtpBundle, - setSessionKeysFromTek, -} from "../turnkey"; +import { buildWalletSignature, sealOtpBundle } from "../turnkey"; +import { setSessionKeysFromTek } from "../session"; import { addLog, bindClick, el } from "../ui"; import { requireAccountId, @@ -127,6 +124,8 @@ export function wireEmailOtpFlows(): void { // The TEK is now the session's API key (OTP_LOGIN registered it). Cache it // as the active session signing key so later signed retries (add passkey, // quote execute, etc.) can stamp with this session via turnkeyStamp(). + // MIGRATION (P6): this OTP-TEK caching is the model passkey/oauth login + // converge on once the login-family knob is ON — see oauth.ts/passkey.ts. if (leg2.status === 200) setSessionKeysFromTek(tek); // One bundle per challenge — force a fresh Challenge for the next run. v3TargetBundle = null; diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/oauth.ts b/apps/examples/grid-global-accounts-example-app/src/flows/oauth.ts index 94ae08789..fd156be91 100644 --- a/apps/examples/grid-global-accounts-example-app/src/flows/oauth.ts +++ b/apps/examples/grid-global-accounts-example-app/src/flows/oauth.ts @@ -2,7 +2,7 @@ import { SANDBOX_SIG } from "../config"; import { apiPost } from "../api-client"; -import { rememberEncryptedSessionSigningKey } from "../turnkey"; +import { rememberEncryptedSessionSigningKey } from "../session"; import { addLog, bindClick, el, wireGenKeyButton } from "../ui"; import { requireAccountId, @@ -51,6 +51,11 @@ export function wireOauthFlows(): void { addLog("OAUTH Verify", data); const d = data as Record; if (d.id) setCtxSession(d.id as string); + // MIGRATION (P6): OAUTH login moves to OAUTH_LOGIN; the knob-ON response + // drops `encryptedSessionSigningKey`, so this becomes the OTP-style + // `setSessionKeysFromTek(clientKeyPair)` path. The shape-detection in + // `rememberEncryptedSessionSigningKey` already no-ops when the field is + // absent — flip this one call once the P3 wire shape settles. rememberEncryptedSessionSigningKey(d.encryptedSessionSigningKey); return JSON.stringify(data, null, 2); }, diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/passkey.ts b/apps/examples/grid-global-accounts-example-app/src/flows/passkey.ts index 069001b5c..c5deb7665 100644 --- a/apps/examples/grid-global-accounts-example-app/src/flows/passkey.ts +++ b/apps/examples/grid-global-accounts-example-app/src/flows/passkey.ts @@ -3,12 +3,21 @@ import { SANDBOX_SIG } from "../config"; import { apiPost, getMode } from "../api-client"; +import { turnkeyStamp } from "../turnkey"; import { + hasSessionSigningKey, + onSessionChange, rememberEncryptedSessionSigningKey, - turnkeyStamp, -} from "../turnkey"; +} from "../session"; import { createRealPasskey, signWithPasskey } from "../webauthn"; -import { addLog, bindClick, el, wireGenKeyButton } from "../ui"; +import { + addLog, + bindClick, + el, + maybeEl, + wireGatedButton, + wireGenKeyButton, +} from "../ui"; import { requireAccountId, requireCredentialId, @@ -141,6 +150,11 @@ export function wirePasskeyFlows(): void { addLog("PASSKEY Verify", data); const d = data as Record; if (d.id) setCtxSession(d.id as string); + // MIGRATION (P6): PASSKEY login moves to STAMP_LOGIN; the knob-ON response + // drops `encryptedSessionSigningKey`, so this becomes the OTP-style + // `setSessionKeysFromTek(clientKeyPair)` path. The shape-detection in + // `rememberEncryptedSessionSigningKey` already no-ops when the field is + // absent — flip this one call once the P2 wire shape settles. rememberEncryptedSessionSigningKey(d.encryptedSessionSigningKey); return JSON.stringify(data, null, 2); }, @@ -235,4 +249,20 @@ export function wirePasskeyFlows(): void { return JSON.stringify(data, null, 2); }, ); + + // The add-retry stamps CREATE_AUTHENTICATORS with the live session's signing + // key in production. Surface that requirement as a disabled-with-tooltip + // button (re-evaluated on session + mode change) instead of throwing on + // click — fixes the old "No client keypair" trap. + const refreshAddRetryGate = wireGatedButton("btn-passkey-add-retry", () => { + if (getMode() !== "production") return null; // sandbox uses the magic value + if (!hasSessionSigningKey()) + return "Log in first — adding a passkey needs a live session to stamp the request."; + return null; + }); + onSessionChange(refreshAddRetryGate); + maybeEl("mode-select")?.addEventListener( + "change", + refreshAddRetryGate, + ); } diff --git a/apps/examples/grid-global-accounts-example-app/src/main.ts b/apps/examples/grid-global-accounts-example-app/src/main.ts index cd956c442..0540ba003 100644 --- a/apps/examples/grid-global-accounts-example-app/src/main.ts +++ b/apps/examples/grid-global-accounts-example-app/src/main.ts @@ -8,6 +8,7 @@ // Thin bootstrap: wire tabs, then each flow module. Behavior lives in the // `flows/` tree + the `config / turnkey / webauthn / api-client / ui` modules. +import { renderChip } from "./session"; import { wireTabs } from "./ui"; import { wireCustomerFlows } from "./flows/customer"; import { wireEmailOtpFlows } from "./flows/email-otp"; @@ -24,4 +25,8 @@ wirePasskeyFlows(); wireManageFlows(); wireMoneyFlows(); +// Paint the initial session chip (empty session) once the DOM + flow gates are +// wired. Flows re-render it as ids / signing keys land. +renderChip(); + console.log("Grid Global Accounts example app loaded."); diff --git a/apps/examples/grid-global-accounts-example-app/src/session.ts b/apps/examples/grid-global-accounts-example-app/src/session.ts new file mode 100644 index 000000000..3f5b3cec4 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/session.ts @@ -0,0 +1,227 @@ +// Session state — the ONE place that holds the client keypair / encrypted +// session-signing-key bundle / TEK, plus the account / credential / session +// ids. Renders the session status chip so the two session models are visible +// (and the "No client keypair" trap is surfaced as disabled-with-tooltip +// instead of a runtime throw). +// +// Two session models funnel through here: +// - "Verify-bundle": a client keypair + an `encryptedSessionSigningKey` +// bundle Grid returns on passkey/oauth Verify, HPKE-decrypted on demand. +// - "OTP-TEK": the TEK private key *is* the session key (OTP login, and — +// post-migration — passkey/oauth too); cached directly, no bundle. + +import { decryptCredentialBundle, getPublicKey } from "@turnkey/crypto"; + +import { el, maybeEl } from "./ui"; + +export interface ClientKeyPair { + privateKey: string; // hex + publicKey: string; // hex, compressed + publicKeyUncompressed: string; // hex, 130 chars (0x04 prefix) +} + +export interface SessionKeys { + apiPublicKey: string; // hex, compressed P-256 + apiPrivateKey: string; // hex +} + +// Which model established the current signing key, for the chip badge. +export type SessionModel = "none" | "otp-tek" | "verify-bundle"; + +let clientKeyPair: ClientKeyPair | null = null; +let lastEncryptedSessionSigningKey: string | null = null; +let cachedSessionKeys: SessionKeys | null = null; +let model: SessionModel = "none"; + +// ----- Client keypair (Verify-bundle model) ----- + +export function setClientKeyPair(kp: ClientKeyPair): void { + clientKeyPair = kp; + // A fresh client key invalidates any session decrypted under the old one. + cachedSessionKeys = null; + lastEncryptedSessionSigningKey = null; + model = "none"; + renderChip(); +} + +export function getClientKeyPair(): ClientKeyPair | null { + return clientKeyPair; +} + +export function rememberEncryptedSessionSigningKey(value: unknown): void { + // MIGRATION (P6): once the login-family knob is ON, passkey/oauth Verify drop + // `encryptedSessionSigningKey` and behave like OTP (the client key is the + // session key). Shape-detection on field-presence already no-ops here when + // the field is absent, so both knob states work unchanged. + if (typeof value === "string" && value) { + lastEncryptedSessionSigningKey = value; + cachedSessionKeys = null; + model = "verify-bundle"; + renderChip(); + } +} + +// ----- OTP-TEK model ----- +// +// There is no encryptedSessionSigningKey bundle — the TEK private key *is* the +// session's API key once login registers it. Cache it directly so +// `turnkeyStamp` can authorize later signed retries without the Verify-style +// clientKeyPair + bundle. +export function setSessionKeysFromTek(tek: { + publicKey: string; + privateKey: string; +}): void { + cachedSessionKeys = { + apiPublicKey: tek.publicKey, + apiPrivateKey: tek.privateKey, + }; + model = "otp-tek"; + renderChip(); +} + +// Resolve the session signing keys, decrypting the Verify bundle on demand. +// Returns null (rather than throwing) when no session is established yet — the +// caller decides how to surface that. Crypto callers use this; UI gates use +// `hasSessionSigningKey()`. +export function resolveSessionKeys(): SessionKeys | null { + if (cachedSessionKeys) return cachedSessionKeys; + if (!clientKeyPair || !lastEncryptedSessionSigningKey) return null; + 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; +} + +// True once a signing key is available *or* derivable (cached TEK, or a client +// keypair + bundle). This is the gate the UI uses to enable/disable buttons. +export function hasSessionSigningKey(): boolean { + if (cachedSessionKeys) return true; + return Boolean(clientKeyPair && lastEncryptedSessionSigningKey); +} + +export function getSessionModel(): SessionModel { + return model; +} + +// ----- Change subscribers ----- +// +// Buttons that need a live session (e.g. "Add passkey" retry in production) +// subscribe here so they can re-evaluate their disabled-with-tooltip state +// whenever the session changes — surfacing the requirement instead of throwing +// on click. + +type SessionListener = () => void; +const listeners: SessionListener[] = []; + +// Subscribe to *signing-key readiness* changes only. Listeners are notified +// when `hasSessionSigningKey()` transitions, not on every account/credential/ +// session id update — so a gated button can't be re-enabled mid-flight by an +// unrelated id write (e.g. `setSessionId`) while its handler is running. +export function onSessionChange(listener: SessionListener): void { + listeners.push(listener); + listener(); // run once so the initial state is applied +} + +let lastSigningKeyReady = false; + +// Repaint the chip on any state change, but only fire listeners when +// signing-key readiness actually flips (the single thing they depend on). +function notifyIfSigningKeyChanged(): void { + const ready = hasSessionSigningKey(); + if (ready === lastSigningKeyReady) return; + lastSigningKeyReady = ready; + for (const listener of listeners) listener(); +} + +// ----- Cross-flow ids (account / credential / session) ----- +// +// Backed by the existing hidden-ish context inputs so manual paste still works; +// reading them here keeps the chip in sync with whatever the flows last set. + +function accountIdEl(): HTMLInputElement { + return el("ctx-account-id"); +} +function credentialIdEl(): HTMLInputElement { + return el("ctx-credential-id"); +} +function sessionIdEl(): HTMLInputElement { + return el("ctx-session-id"); +} + +// First-call-wins by design: the account id is established once (Create +// Customer) and shared across every credential-type tab, so a later per-type +// flow can't clobber it. Credential/session ids below are per-type and do +// overwrite. To switch accounts, clear the field in the UI. +export function setAccountId(id: string): void { + if (!accountIdEl().value) accountIdEl().value = id; + renderChip(); +} +export function setCredentialId(id: string): void { + credentialIdEl().value = id; + renderChip(); +} +export function setSessionId(id: string): void { + sessionIdEl().value = id; + renderChip(); +} + +export function getAccountId(): string { + return accountIdEl().value.trim(); +} +export function getCredentialId(): string { + return credentialIdEl().value.trim(); +} +export function getSessionId(): string { + return sessionIdEl().value.trim(); +} + +// ----- Status chip ----- + +const MODEL_LABEL: Record = { + none: "—", + "otp-tek": "OTP-TEK", + "verify-bundle": "Verify-bundle", +}; + +// Escape interpolated values: chip fields show server-sourced ids +// (credential / session / account), so a hostile value like +// `` must not become live markup in innerHTML. +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function chipField(label: string, value: string, ok?: boolean): string { + const cls = ok === undefined ? "" : ok ? " chip-ok" : " chip-none"; + const shown = escapeHtml(value || "—"); + return `${escapeHtml( + label, + )} ${shown}`; +} + +export function renderChip(): void { + const chip = maybeEl("session-chip"); + if (chip) { + const ready = hasSessionSigningKey(); + chip.innerHTML = [ + chipField("account", getAccountId()), + chipField("credential", getCredentialId()), + chipField("session", getSessionId()), + chipField("signing key", ready ? "ready" : "none", ready), + chipField("model", MODEL_LABEL[model]), + ].join(""); + } + // Repaint above happens on every state change; listeners only fire when the + // signing-key readiness flips. + notifyIfSigningKeyChanged(); +} diff --git a/apps/examples/grid-global-accounts-example-app/src/turnkey.ts b/apps/examples/grid-global-accounts-example-app/src/turnkey.ts index 060938e9d..aac6feed1 100644 --- a/apps/examples/grid-global-accounts-example-app/src/turnkey.ts +++ b/apps/examples/grid-global-accounts-example-app/src/turnkey.ts @@ -1,103 +1,40 @@ -// Turnkey crypto: P-256 keygen, HPKE seal, wallet signature, session-key state, -// and the X-Stamp builder. +// Turnkey crypto: P-256 keygen, HPKE seal, wallet signature, and the X-Stamp +// builder. Session-key *state* lives in `session.ts`; this module only does the +// crypto and reads/writes that state through it. import { - decryptCredentialBundle, formatHpkeBuf, generateP256KeyPair, - getPublicKey, hpkeEncrypt, } from "@turnkey/crypto"; import { signWithApiKey } from "@turnkey/api-key-stamper"; import { TURNKEY_STAMP_SCHEME } from "./config"; +import { type ClientKeyPair, resolveSessionKeys, setClientKeyPair } from "./session"; -// ----- Production-mode key state ----- -// -// Generated client-side at the first call to `generateClientKeyPair`. The +// Generate the client-side P-256 keypair (Verify-bundle model). 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. - -export interface ClientKeyPair { - privateKey: string; // hex - publicKey: string; // hex, compressed - publicKeyUncompressed: string; // hex, 130 chars (0x04 prefix) -} - -export 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; - +// `clientPublicKey` on Verify; the private key stays client-side to +// HPKE-decrypt the `encryptedSessionSigningKey` Grid hands back. Stored in +// `session.ts` so a session decrypted under one keypair stays valid across tabs. export function generateClientKeyPair(): ClientKeyPair { const kp = generateP256KeyPair(); - clientKeyPair = { + const clientKeyPair: 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; + setClientKeyPair(clientKeyPair); return clientKeyPair; } -export function rememberEncryptedSessionSigningKey(value: unknown): void { - if (typeof value === "string" && value) { - lastEncryptedSessionSigningKey = value; - cachedSessionKeys = null; - } -} - -// OTP_LOGIN / STAMP_LOGIN model: there is no encryptedSessionSigningKey bundle -// — the TEK private key *is* the session's API key once login registers it. -// Cache it directly so turnkeyStamp() can authorize later signed retries -// (e.g. adding a passkey) without the Verify-style clientKeyPair + bundle. -export function setSessionKeysFromTek(tek: { - publicKey: string; - privateKey: string; -}): void { - cachedSessionKeys = { - apiPublicKey: tek.publicKey, - apiPrivateKey: tek.privateKey, - }; -} - -function decryptSessionKeysOrThrow(): SessionKeys { - if (cachedSessionKeys) return cachedSessionKeys; - if (!clientKeyPair) - throw new Error( - "No client keypair — run a Verify in production mode first.", - ); - if (!lastEncryptedSessionSigningKey) +export async function turnkeyStamp(payload: string): Promise { + const keys = resolveSessionKeys(); + if (!keys) throw new Error( - "No encryptedSessionSigningKey — run a Verify in production mode first.", + "No session signing key — log in (Verify) first to establish a session.", ); - 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; -} - -export async function turnkeyStamp(payload: string): Promise { - const { apiPublicKey, apiPrivateKey } = decryptSessionKeysOrThrow(); + const { apiPublicKey, apiPrivateKey } = keys; // `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` diff --git a/apps/examples/grid-global-accounts-example-app/src/ui.ts b/apps/examples/grid-global-accounts-example-app/src/ui.ts index ad103e97d..edf19afa3 100644 --- a/apps/examples/grid-global-accounts-example-app/src/ui.ts +++ b/apps/examples/grid-global-accounts-example-app/src/ui.ts @@ -108,6 +108,30 @@ export function wireGenKeyButton(btnId: string, targetInputId: string): void { }); } +// ----- Session-gated buttons ----- +// +// Disable a button with an explanatory tooltip when it can't run yet (e.g. a +// signed retry that needs a live session in production), instead of letting the +// click throw a cryptic error. `evaluate()` returns null when enabled, or the +// tooltip/disabled reason when it should be blocked. + +export function wireGatedButton( + btnId: string, + evaluate: () => string | null, +): () => void { + const btn = maybeEl(btnId); + if (!btn) return () => {}; + return () => { + const reason = evaluate(); + btn.disabled = reason !== null; + if (reason) { + btn.title = reason; + } else { + btn.removeAttribute("title"); + } + }; +} + // ----- Tab switching ----- export function wireTabs(): void { From 1ff29c6670dcf717fcff3ab88778fcbc124b48d5 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 12 Jun 2026 03:13:49 -0700 Subject: [PATCH 77/90] [js] gga example app: prune dead flows + stale PR-number comments (#28473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Prune dead / reject-only flows and fix stale PR-number comments: demote the EMAIL_OTP "Add second" reject demo and OAUTH rechallenge no-op to Advanced (or remove), fold the duplicate EMAIL_OTP rechallenge into guided login, and reword the stale `"PR #28427:"` / `"PR 4 flow:"` comments to describe behavior. ## Why P4 example app, PR 3 in `40-example-app-design.md` §2/§5. These flows existed only to exercise reject paths or duplicated a guided step, and the PR-number comments anchor readers to specific (now-irrelevant) PRs rather than describing what the code does. Small, low-risk cleanup independent of the UI restructure. ## Place in the stack Base: #28472 (session.ts + status chip). Fourth PR of the **P4 example-app** stack. ## Notable points - Deletions/rewrites only; no new behavior. Reject/no-op demos are demoted, not silently lost. - Manual test tool; type gate: `build` + `lint`/`format`. --- Part of the Turnkey login-family migration program. See `sparkcore/sparkcore/grid/docs/login-migration/00-program-plan.md`. GitOrigin-RevId: 5158156f8af7183ce150e63815e4d2dd563b377b --- .../index.html | 38 +++++-------------- .../src/flows/email-otp.ts | 18 +-------- .../src/flows/oauth.ts | 18 +-------- .../src/webauthn.ts | 4 +- 4 files changed, 14 insertions(+), 64 deletions(-) diff --git a/apps/examples/grid-global-accounts-example-app/index.html b/apps/examples/grid-global-accounts-example-app/index.html index d81b39e56..fd579e23e 100644 --- a/apps/examples/grid-global-accounts-example-app/index.html +++ b/apps/examples/grid-global-accounts-example-app/index.html @@ -235,10 +235,10 @@

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. + Pick a mode at the top. Signed-retry flows are two-step — step 1 issues a + 202 challenge (requestId / payloadToSign you can + inspect), step 2 forwards a Grid-Wallet-Signature: a magic + value in sandbox, a real session stamp in production.

@@ -446,21 +446,12 @@

Verify → session (secure OTP)

- Rechallenge (re-issue OTP) -

-

Uses Credential ID from Wallet Context.

- -
-
- -
-

- Add second EMAIL_OTP via signed retry + Add second EMAIL_OTP (expected-reject demo)

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

@@ -578,15 +569,6 @@

Verify → session

-
-

Rechallenge

-

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

- -
-
-

Add additional OAUTH via signed retry @@ -732,9 +714,9 @@

Create credential

Session challenge

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

{ - 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", diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/oauth.ts b/apps/examples/grid-global-accounts-example-app/src/flows/oauth.ts index fd156be91..889bb2f39 100644 --- a/apps/examples/grid-global-accounts-example-app/src/flows/oauth.ts +++ b/apps/examples/grid-global-accounts-example-app/src/flows/oauth.ts @@ -1,4 +1,4 @@ -// OAUTH lifecycle: create, verify (→ session), rechallenge (no-op), add. +// OAUTH lifecycle: create, verify (→ session), add. import { SANDBOX_SIG } from "../config"; import { apiPost } from "../api-client"; @@ -61,22 +61,6 @@ export function wireOauthFlows(): void { }, ); - 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", diff --git a/apps/examples/grid-global-accounts-example-app/src/webauthn.ts b/apps/examples/grid-global-accounts-example-app/src/webauthn.ts index d694e3495..9abb0fbef 100644 --- a/apps/examples/grid-global-accounts-example-app/src/webauthn.ts +++ b/apps/examples/grid-global-accounts-example-app/src/webauthn.ts @@ -92,8 +92,8 @@ export async function signWithPasskey( "No challenge — issue a session challenge (step above) first.", ); } - // PR #28427: Turnkey's WebAuthn challenge is the UTF-8 bytes of the - // sha256-hex challenge string returned by /challenge — NOT base64url-decoded. + // Turnkey's WebAuthn challenge is the UTF-8 bytes of the sha256-hex challenge + // string returned by /challenge — NOT base64url-decoded. const challenge = new TextEncoder().encode(challengeValue); const allowCredentials: PublicKeyCredentialDescriptor[] = credentialId ? [{ type: "public-key", id: b64UrlToBytes(credentialId) as BufferSource }] From 9b760c41ad1210db9a3145e299b6d6e9c0b9a070 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 12 Jun 2026 03:20:47 -0700 Subject: [PATCH 78/90] [js] gga example app: sandbox/production mode split driven by SANDBOX_MAGIC (#28474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Make mode (sandbox vs production) the primary switch: move the scattered `value="sandbox-..."` HTML seeds into a single `SANDBOX_MAGIC` map (`mode.ts`/`config.ts`), drive field visibility + seeding from mode in JS, persist the chosen mode to `localStorage`, and hide ceremony (Touch ID) buttons in sandbox / hide magic fields in production. ## Why P4 example app, PR 4 in `40-example-app-design.md` §1.4/§5. Every field was pre-seeded with `sandbox-*` placeholders indistinguishable from real values, and `SANDBOX_SIG` was silently injected into signed-retry headers — in production mode these are wrong with no UI signal which fields are magic. Sourcing seeds from one labeled constant map, injected only in sandbox mode, removes the "looks real" problem at the source; persisting mode stops a reload silently reverting to sandbox. ## Place in the stack Base: #28473 (prune dead flows). Fifth PR of the **P4 example-app** stack. ## Notable points - Field visibility/seeding is now JS-driven keyed on mode rather than hardcoded in `index.html`. - Manual test tool; type gate: `build` + `lint`/`format`. Manual smoke: sandbox click-through + (where a device is available) one production Touch-ID round-trip. --- Part of the Turnkey login-family migration program. See `sparkcore/sparkcore/grid/docs/login-migration/00-program-plan.md`. GitOrigin-RevId: 7f45be16fefa89a0d169b06d53d7183d06d8004b --- .../index.html | 177 ++++++++++-------- .../src/config.ts | 31 +++ .../src/main.ts | 5 + .../src/mode.ts | 91 +++++++++ 4 files changed, 228 insertions(+), 76 deletions(-) create mode 100644 apps/examples/grid-global-accounts-example-app/src/mode.ts diff --git a/apps/examples/grid-global-accounts-example-app/index.html b/apps/examples/grid-global-accounts-example-app/index.html index fd579e23e..7dc4d3945 100644 --- a/apps/examples/grid-global-accounts-example-app/index.html +++ b/apps/examples/grid-global-accounts-example-app/index.html @@ -230,6 +230,27 @@ .chip-field.chip-none { color: #ff6b6b; } + .magic-pill { + display: inline-block; + margin-left: 6px; + padding: 0 5px; + border-radius: 3px; + background: #4a3a00; + border: 1px solid #7a5c00; + color: #ffd166; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.5px; + vertical-align: middle; + } + #sandbox-legend { + color: #888; + font-size: 11px; + margin-top: 6px; + } + #sandbox-legend code { + color: #ffd166; + } @@ -261,14 +282,21 @@

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 + Chosen once and remembered across reloads. Production hides every + magic-value field (nothing fake on screen) and shows the real-ceremony + (Touch ID) buttons; it persists the client P-256 keypair + the encrypted + session signing key from Verify, HPKE-decrypts via + @turnkey/crypto, and stamps real payloadToSign values via - @turnkey/api-key-stamper. + @turnkey/api-key-stamper. Sandbox hides the ceremony + buttons and seeds the magic values below (each flagged + magic). +

+

+ Sandbox magic strings: sandbox-valid-signature, + 000000, sandbox-valid-oidc-token, + sandbox-valid-passkey-signature — accepted by the sandbox + backend in place of real ceremony output.

@@ -438,8 +466,10 @@

Verify → session (secure OTP)

- - +
+ + +
@@ -539,22 +569,20 @@

OAUTH lifecycle

Create credential

- - +
+ + +

Verify → session

- - +
+ + +
Verify → session

Add additional OAUTH via signed retry

- - +
+ + +
@@ -673,40 +701,42 @@

Create credential

"Create" and "Add additional" against real Turnkey. The sub-org's RP ID must match this page's origin.

-
- - - - - + + +
+
+ + +
+
- - Attestation clientDataJSON + +
+
- + + +
@@ -742,35 +772,30 @@

Verify → session

id="passkey-verify-request-id" placeholder="auto-filled from challenge" /> - - - - - + + +
+
+ + +
+
- + + +

Click below to sign the issued challenge with your real passkey (Touch ID) — it fills the assertion fields above.

-
diff --git a/apps/examples/grid-global-accounts-example-app/src/config.ts b/apps/examples/grid-global-accounts-example-app/src/config.ts index e9726befd..9a6a620c5 100644 --- a/apps/examples/grid-global-accounts-example-app/src/config.ts +++ b/apps/examples/grid-global-accounts-example-app/src/config.ts @@ -13,3 +13,34 @@ export const API_BASE = "/api"; // Turnkey API stamp scheme — must match what `@turnkey/api-key-stamper` emits. export const TURNKEY_STAMP_SCHEME = "SIGNATURE_SCHEME_TK_API_P256"; + +// `localStorage` key for the persisted mode so a reload keeps the chosen mode +// instead of silently reverting to sandbox. +export const MODE_STORAGE_KEY = "gga-example-app-mode"; + +// ----- Sandbox magic values ----- +// +// The single source of truth for every fake "looks-real" field. Keyed by input +// element id → the magic value the sandbox backend accepts. In sandbox mode +// these are seeded into the fields (and the field gets a "magic" pill) by +// `mode.ts`; in production mode the same fields are hidden so nothing fake is +// ever on screen. This replaces the scattered `value="sandbox-..."` attributes +// that made fake data indistinguishable from real values. +export const SANDBOX_MAGIC: Record = { + // EMAIL_OTP — sandbox always accepts the fixed code. + "email_otp-v3-code": "000000", + // OAUTH — magic OIDC tokens (verify input + JWT-shaped create/add identities). + "oauth-verify-oidc": "sandbox-valid-oidc-token", + "oauth-create-oidc": + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJzdWIiOiJzYW5kYm94LXVzZXItMSJ9.sig", + "oauth-add-oidc": + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwic3ViIjoic2FuZGJveC11c2VyLTIifQ.sig", + // PASSKEY — magic attestation (create) + assertion (verify) blobs. + "passkey-create-challenge": "c2FuZGJveC1jaGFsbGVuZ2U", + "passkey-create-cred-id-raw": "c2FuZGJveC1jcmVkLWlk", + "passkey-create-client-data-json": "c2FuZGJveC1jbGllbnREYXRhSlNPTg", + "passkey-create-attestation-object": "c2FuZGJveC1hdHRlc3RhdGlvbk9iamVjdA", + "passkey-verify-signature": "sandbox-valid-passkey-signature", + "passkey-verify-auth-data": "c2FuZGJveC1hdXRoLWRhdGE", + "passkey-verify-client-data-json": "c2FuZGJveC1jbGllbnQtZGF0YQ", +}; diff --git a/apps/examples/grid-global-accounts-example-app/src/main.ts b/apps/examples/grid-global-accounts-example-app/src/main.ts index 0540ba003..50c06563b 100644 --- a/apps/examples/grid-global-accounts-example-app/src/main.ts +++ b/apps/examples/grid-global-accounts-example-app/src/main.ts @@ -8,6 +8,7 @@ // Thin bootstrap: wire tabs, then each flow module. Behavior lives in the // `flows/` tree + the `config / turnkey / webauthn / api-client / ui` modules. +import { initMode } from "./mode"; import { renderChip } from "./session"; import { wireTabs } from "./ui"; import { wireCustomerFlows } from "./flows/customer"; @@ -17,6 +18,10 @@ import { wirePasskeyFlows } from "./flows/passkey"; import { wireManageFlows } from "./flows/manage"; import { wireMoneyFlows } from "./flows/money"; +// Resolve mode (persisted) + apply field visibility / magic seeding first, so +// flows wire against the correct initial state. +initMode(); + wireTabs(); wireCustomerFlows(); wireEmailOtpFlows(); diff --git a/apps/examples/grid-global-accounts-example-app/src/mode.ts b/apps/examples/grid-global-accounts-example-app/src/mode.ts new file mode 100644 index 000000000..905dcc7b1 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/mode.ts @@ -0,0 +1,91 @@ +// Sandbox / production mode: chosen once, persisted to localStorage, and the +// single driver of magic-value seeding + field/button visibility. +// +// - production: every magic-value field is hidden (nothing fake on screen); +// real-ceremony (Touch ID) buttons are shown. Values come from real +// ceremonies or guided flows. +// - sandbox: magic-value fields are shown, seeded from `SANDBOX_MAGIC`, and +// labeled with a "magic" pill; real-ceremony buttons are hidden. + +import { MODE_STORAGE_KEY, SANDBOX_MAGIC, type Mode } from "./config"; +import { el, maybeEl } from "./ui"; + +function readPersistedMode(): Mode { + try { + return localStorage.getItem(MODE_STORAGE_KEY) === "production" + ? "production" + : "sandbox"; + } catch { + return "sandbox"; + } +} + +function persistMode(mode: Mode): void { + try { + localStorage.setItem(MODE_STORAGE_KEY, mode); + } catch { + // localStorage unavailable (private mode etc.) — non-fatal, mode just + // won't survive a reload. + } +} + +// Wrapper for a magic field, so the whole label+input+pill block hides in +// production. Looked up lazily by the field's input id. +function magicWrapper(id: string): HTMLElement | null { + return document.querySelector(`[data-magic-for="${id}"]`); +} + +function ensurePill(id: string): void { + const wrapper = magicWrapper(id); + if (!wrapper || wrapper.querySelector(".magic-pill")) return; + const label = wrapper.querySelector("label"); + if (!label) return; + const pill = document.createElement("span"); + pill.className = "magic-pill"; + pill.textContent = "magic"; + pill.title = "Sandbox-only placeholder accepted by the sandbox backend."; + label.appendChild(pill); +} + +function applyMode(mode: Mode): void { + const sandbox = mode === "sandbox"; + + // Magic fields: seed + pill + show in sandbox; clear + hide in production. + for (const [id, value] of Object.entries(SANDBOX_MAGIC)) { + const wrapper = magicWrapper(id); + if (wrapper) wrapper.style.display = sandbox ? "" : "none"; + const field = maybeEl(id); + if (!field) continue; + if (sandbox) { + // Only seed when empty so we never stomp a value the user typed. + if (!field.value) field.value = value; + ensurePill(id); + } else if (field.value === value) { + // Drop a leftover magic value when switching to production so nothing + // fake is submitted; leave any user-entered value untouched. + field.value = ""; + } + } + + // Real-ceremony (Touch ID) buttons: only meaningful in production. + for (const btn of document.querySelectorAll("[data-ceremony]")) { + btn.style.display = sandbox ? "none" : ""; + } + + // Sandbox-only legend (the magic-string list moved out of the mode