Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 39 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,49 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout sumit-react
uses: actions/checkout@v4
with:
path: sumit-react

# `@digitizers/sumit-api` is consumed via `file:../sumit-api` until it
# ships to npm, so CI must check it out as a sibling and build its dist/.
- name: Checkout sumit-api (peer dependency)
uses: actions/checkout@v4
with:
repository: Digitizers/sumit-api
path: sumit-api

- uses: pnpm/action-setup@v4
with:
version: 10.26.0

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install
- run: pnpm typecheck
- run: pnpm test
- run: pnpm build
cache-dependency-path: |
sumit-react/pnpm-lock.yaml
sumit-api/pnpm-lock.yaml

- name: Build sumit-api
working-directory: sumit-api
run: |
pnpm install --frozen-lockfile
pnpm build

- name: Install sumit-react
working-directory: sumit-react
run: pnpm install --frozen-lockfile

- name: Typecheck
working-directory: sumit-react
run: pnpm typecheck

- name: Test
working-directory: sumit-react
run: pnpm test

- name: Build
working-directory: sumit-react
run: pnpm build
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,4 @@ dist
.env
.env.local
coverage
pnpm-lock.yaml
*.code-workspace
50 changes: 50 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# CLAUDE.md

Guidance for AI assistants working in this repository.

## Project

`@digitizers/sumit-react` — React component (`<SumitCheckout />`), checkout state hook (`useSumitCheckout`), and Next.js route helpers (`createSumitChargeRoute`, `createSumitWebhookRoute`) for SUMIT / OfficeGuy / Upay payments.

Companion package: [`@digitizers/sumit-api`](https://github.com/Digitizers/sumit-api) (peer dependency).

## Architecture

Two entry points, kept strictly separate:

| Path | Surface | Notes |
| --- | --- | --- |
| `./client` | `SumitCheckout`, `useSumitCheckout`, `loadSumitPayments`, `createSingleUseToken` | Browser-only. Loads `payments.js` from SUMIT. **Card data never touches our server** — SUMIT's script reads form fields directly. |
| `./next` | `createSumitChargeRoute`, `createSumitWebhookRoute`, `verifySumitSharedSecret` | Server-only. Uses Web Standard `Request` / `Response` so it works in Edge and Node runtimes. |

**Never import from `./next` in client code.** The server bundle holds the SUMIT `apiKey`; leaking it to the browser is a P0.

## Conventions

- **Web Standards everywhere on the server.** No `node:*` imports unless absolutely necessary. The webhook timing-safe compare uses `crypto.subtle` (Web Crypto) so the route works in Edge.
- **Strict TypeScript.** No `any`. `forwardRef` + `useImperativeHandle` over prop drilling for imperative checkout control.
- **Comments only explain WHY.** Don't restate what the code does.
- **Tests are colocated** (`*.test.ts(x)`) using Vitest with happy-dom. New behavior gets a test.

## Security model

This package handles payments. Three rules:

1. **Server credentials never reach the client.** The `apiKey` is only consumed by `createSumitChargeRoute`. The `apiPublicKey` may be exposed.
2. **Webhook verification is constant-time AND length-independent.** `verifySumitSharedSecret` hashes both the candidate and the secret to a fixed-length digest before comparing — a length-dependent path leaks the secret's byte-length via response timing.
3. **Tokenization is single-flight.** `<SumitCheckout />` uses a synchronous `useRef` guard so two rapid submits cannot both fire `CreateToken` (a stale-closure on `useState` would let the second slip through).

All payloads forwarded to clients pass through `redactSumitPayload` from `@digitizers/sumit-api`.

## Workflow

```bash
pnpm install
pnpm test # vitest run
pnpm typecheck # tsc --noEmit
pnpm build # tsc → dist/
```

Local development assumes `sumit-api` is checked out as a sibling directory (the `devDependencies` entry uses `file:../sumit-api`).

Branches: `fix/*`, `feat/*`, `chore/*`. PRs to `main`. Conventional-commit-ish messages.
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @digitizers/sumit-react

[![npm](https://img.shields.io/npm/v/@digitizers/sumit-react.svg)](https://www.npmjs.com/package/@digitizers/sumit-react)
[![types](https://img.shields.io/npm/types/@digitizers/sumit-react.svg)](https://www.npmjs.com/package/@digitizers/sumit-react)
[![license](https://img.shields.io/npm/l/@digitizers/sumit-react.svg)](LICENSE)
[![react](https://img.shields.io/badge/react-%E2%89%A518-61DAFB?logo=react&logoColor=white)](package.json)
[![next](https://img.shields.io/badge/next.js-app%20router-000000?logo=next.js&logoColor=white)](https://nextjs.org)

> React components and Next.js route helpers for [SUMIT / OfficeGuy / Upay](https://sumit.co.il) payments. The companion to [`@digitizers/sumit-api`](https://github.com/Digitizers/sumit-api).

Ship a working SUMIT checkout flow in a React or Next.js app with two files: a Client Component and a route handler.
Expand Down Expand Up @@ -150,6 +156,18 @@ Accepts JSON, `application/x-www-form-urlencoded`, and SUMIT's `json=<serialized

---

## Security

| Concern | How it's handled |
| --- | --- |
| **Card data exposure** | SUMIT's `payments.js` reads card fields directly and returns a `SingleUseToken`. Card numbers, expiry, and CVV never reach your server or your component state. |
| **Server credential leakage** | The full `apiKey` lives only in `createSumitChargeRoute`; `./client` and `./next` are separate exports so client bundles cannot transitively pull the server secret. |
| **Webhook spoofing** | `verifySumitSharedSecret` hashes both the candidate and the secret to a fixed 32-byte digest before comparing — the comparison is constant-time **and** length-independent, so response timing leaks neither secret content nor secret length. |
| **Double-submit / token reuse** | `<SumitCheckout />` uses a synchronous ref guard so two rapid submits cannot both fire `CreateToken` (single-use tokens are exactly that — single-use). |
| **Logging sensitive data** | Every event the route helpers return passes through `redactSumitPayload` from `@digitizers/sumit-api`. |

---

## API surface

```ts
Expand Down
Loading
Loading