@rio-pay/sdk 0.0.0-stage → 0.0.1-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -2
- package/dist/brand.d.ts +12 -0
- package/dist/checkout/react.d.ts +5 -0
- package/dist/checkout/react.js +208 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +1 -0
- package/dist/components/payments-checkout.d.ts +73 -0
- package/dist/components/payments-modal.d.ts +245 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +0 -0
- package/dist/lib/create-charge.d.ts +28 -0
- package/dist/lib/events.d.ts +10 -0
- package/dist/lib/payments-client.d.ts +48 -0
- package/dist/lib/payments-options.d.ts +225 -0
- package/dist/lib/payments-pay.d.ts +82 -0
- package/dist/lib/payments-solana.d.ts +58 -0
- package/dist/lib/payments-sse.d.ts +26 -0
- package/dist/lib/payments-wallet.d.ts +80 -0
- package/dist/lib/payments.d.ts +348 -0
- package/dist/lib/rio-client.d.ts +48 -0
- package/dist/lib/webhook.d.ts +32 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +1 -0
- package/package.json +83 -4
package/README.md
CHANGED
|
@@ -1,3 +1,130 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rio-pay/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The SDK for Rio USDC payments: the checkout widget, shared types and the utilities an integrator's backend needs (webhook signature verification today). It is deliberately small: no route handlers and no framework glue (every integrator wants their own), and no response parsers yet (the API shapes may still change).
|
|
4
|
+
|
|
5
|
+
**Status: 0.x, alpha.** Published to npm under the `next` tag only until the first stable release. Expect breaking changes until 1.0.
|
|
6
|
+
|
|
7
|
+
## Entries
|
|
8
|
+
|
|
9
|
+
| Import | What it is | Runs in |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `@rio-pay/sdk` | Types only: `CreatedCharge`, `ChargeCreateResult`, `ChargeStatus`, `Confirmation`, `CheckoutCopy`, `PlatformChargeStatus`, `PostCreateChargeOptions`, `WebhookEvent`, `WebhookEventType`. No runtime code. | anywhere |
|
|
12
|
+
| `@rio-pay/sdk/client` | `createRioClient({ apiKey })`: `charges.create` returns the charge as the widget takes it, `charges.retrieve` the status its poll takes; failures are `RioError`. Server-side only. | Node 18+, Bun, edge runtimes |
|
|
13
|
+
| `@rio-pay/sdk/server` | `verifyWebhook`, `signWebhook` (for your own tests), `SIGNATURE_HEADER`, `DEFAULT_TOLERANCE_SECONDS`. Web Crypto only, no React. | Node 18+, Bun, edge runtimes |
|
|
14
|
+
| `@rio-pay/sdk/checkout/react` | The widget: `CheckoutWidget` (a client module, `"use client"`), the optional `postCreateCharge` fetch adapter, and the prop types (`CheckoutWidgetProps`, `PostCreateChargeOptions`, `CheckoutCopy`, `CheckoutState`, `CheckoutError`). React is an optional peer dependency (`^18.2 \|\| ^19`); you only need it for this entry. | browsers, React |
|
|
15
|
+
|
|
16
|
+
## Verify a webhook
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import type { WebhookEvent } from "@rio-pay/sdk";
|
|
20
|
+
import { verifyWebhook, SIGNATURE_HEADER } from "@rio-pay/sdk/server";
|
|
21
|
+
|
|
22
|
+
export async function POST(req: Request) {
|
|
23
|
+
const body = await req.text(); // the RAW body: re-serializing parsed JSON changes the bytes and breaks the signature
|
|
24
|
+
const ok = await verifyWebhook({
|
|
25
|
+
secret: process.env.WEBHOOK_SECRET!,
|
|
26
|
+
header: req.headers.get(SIGNATURE_HEADER) ?? "",
|
|
27
|
+
body,
|
|
28
|
+
});
|
|
29
|
+
if (!ok) return new Response("bad signature", { status: 400 });
|
|
30
|
+
|
|
31
|
+
const event = JSON.parse(body) as WebhookEvent;
|
|
32
|
+
// Delivery is at-least-once: dedupe on event.id before acting.
|
|
33
|
+
return new Response("ok");
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`verifyWebhook` returns `false` (never throws) for a malformed header, a timestamp more than 300 seconds from your clock (`toleranceSeconds` to change it), or a signature that does not match. Several `v1` values in one header (a secret rotation) are fine: any match passes. The comparison is constant time. The service's `service/src/core/crypto.ts` is the reference implementation; a parity test keeps the two byte-for-byte equal.
|
|
38
|
+
|
|
39
|
+
## Checkout widget
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
import { CheckoutWidget, postCreateCharge } from "@rio-pay/sdk/checkout/react";
|
|
43
|
+
|
|
44
|
+
<CheckoutWidget
|
|
45
|
+
orderId={order.id}
|
|
46
|
+
merchantName="Moon Lantern"
|
|
47
|
+
// Calls YOUR route. The route decides the amount and creates the charge with your API key.
|
|
48
|
+
createCharge={() => postCreateCharge("/api/checkout", { orderId: order.id })}
|
|
49
|
+
// Your route that returns the authoritative status for a charge (a PlatformChargeStatus), or null to turn polling off.
|
|
50
|
+
statusUrl={(chargeId) => `/api/checkout/status?chargeId=${encodeURIComponent(chargeId)}&orderId=${encodeURIComponent(order.id)}`}
|
|
51
|
+
onPaid={(status) => { /* status.confirmation is "provisional", then "final" */ }}
|
|
52
|
+
/>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
It shows a "Pay with USDC" button, opens a modal, connects the buyer's wallet (EVM and Solana) and walks them through paying a charge. It has no styles to import (the CSS ships inside the component).
|
|
56
|
+
|
|
57
|
+
`createCharge` and `statusUrl` are required: the widget has no default routes. The `statusUrl` route may return the service charge from `GET /v1/charges/:id` as is (`id`, `status`, `confirmed`, `confirmation`, `collected_amount`) or the same fields in camelCase (`chargeId`, `collectedAmount`). **Scope the status route to the buyer's own order.** It reads the charge with your API key, so it must not answer for a charge that is not theirs: send the order id along (as above), have the create route record which charge belongs to the order, and in the status route load the signed-in buyer's order and return 404 unless its charge id matches, before calling the client or `fetch`.
|
|
58
|
+
|
|
59
|
+
### The create route and `postCreateCharge`
|
|
60
|
+
|
|
61
|
+
Two shapes, kept apart. Your **HTTP route** answers a success (2xx) with the created charge as flat JSON (a `CreatedCharge`, not wrapped), and a failure with an error status and `{ error, code? }`. A **409** means the order already has a different charge (for example its amount changed): create a new order id. The **`createCharge` callback** resolves to a `ChargeCreateResult`: `{ ok: true, charge }` or `{ ok: false, status, error, code? }`.
|
|
62
|
+
|
|
63
|
+
`postCreateCharge(url, body, options?)` is the adapter between the two, for the common case. It POSTs `body` (JSON-stringified unless it is already a string) to your route and never throws: a network error, an abort, a non-JSON body or a 2xx without an object charge resolve to `{ ok: false, status: 503 }`. Every option is optional:
|
|
64
|
+
|
|
65
|
+
| Option | Default | What it does |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `method` | `"POST"` | `"POST"`, `"PUT"` or `"PATCH"`. |
|
|
68
|
+
| `headers` | none | Merged over the default `content-type: application/json` (so CSRF or auth headers work). A function is called on every request, sync or async, so it can read a token at call time. |
|
|
69
|
+
| `credentials` | `fetch`'s default | Passed to `fetch` as is (for example `"include"`). |
|
|
70
|
+
| `signal` | none | An `AbortSignal`. An abort resolves to a 503 result. |
|
|
71
|
+
| `fetch` | the global `fetch` | A custom `fetch`, for tests or a wrapped client. Resolved at call time. |
|
|
72
|
+
| `selectCharge(json)` | the body itself | Picks the charge out of a success body, for routes that answer `{ data: charge }` or `{ charge }`. |
|
|
73
|
+
| `selectError(json)` | top-level `error` and `code` strings | Returns `{ error?, code? }` from a failure body. |
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
createCharge={() =>
|
|
77
|
+
postCreateCharge("/api/checkout", { orderId: order.id }, {
|
|
78
|
+
headers: () => ({ "x-csrf-token": readCsrfToken() }),
|
|
79
|
+
credentials: "include",
|
|
80
|
+
selectCharge: (json) => (json as { data: unknown }).data,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
You can also write your own `createCharge` that resolves to a `ChargeCreateResult` (any function will do). **The widget validates the charge either way**, so it does not matter how your route fetched it: an `http` service URL on a public host, a URL with embedded credentials, or a charge missing the client secret or another load-bearing field is treated as a failed create (status 503, the same controlled copy as any create failure) and the client secret is never sent anywhere.
|
|
86
|
+
|
|
87
|
+
The widget never sends an amount and never holds your API key. Your server route calls `POST /v1/charges` (see `internal/docs/API.md`) and returns the charge as JSON with `chargeId`, `clientSecret`, `serviceUrl`, `amount`, `chain`, `chargeAddress`, `status`, `confirmed`, `confirmation` and `expiresAt`. The client secret stays in component state only. `serviceUrl` must be `https` (plain `http` is accepted only for `localhost`, `127.0.0.1` and `[::1]`), because the secret is sent there. After that the browser talks to the service directly, with per-charge CORS.
|
|
88
|
+
|
|
89
|
+
Props worth knowing: `presentation` (`"modal"` or `"inline"`), `appearance` (`"auto" | "light" | "dark"`), `copy` (a `Partial<CheckoutCopy>` that overrides any string), `manualPayment`, `defaultOpen`, `successSlot`, `onStatusChange`, `onError`. Theme with the CSS variables `--sc-accent`, `--sc-radius`, `--sc-bg`, `--sc-fg`, `--sc-muted`, `--sc-border`, `--sc-success`, `--sc-danger`.
|
|
90
|
+
|
|
91
|
+
## Design rules
|
|
92
|
+
|
|
93
|
+
One thing to do: the buyer sees an amount, picks which USDC to pay with, connects a wallet and pays.
|
|
94
|
+
|
|
95
|
+
- **One button, one modal.** The host page shows a single "Pay with USDC" button that opens a native `<dialog>` modal (`presentation="inline"` renders the same content in place for hosts that already sit inside their own dialog). The charge is created when the modal first opens, not on page load.
|
|
96
|
+
- **The modal:** a fixed read-only amount; a token chip that says just "USDC" until a wallet is connected, then a dropdown of the connected wallet's options that hold USDC (USDC with the chain's logo and name as a small subscript: Sei Testnet / Base Sepolia / Solana, all service data, never hardcoded; a zero balance is not listed, a balance below the amount is listed but disabled, the best payable option is preselected, and a wallet with none shows the no-USDC screen); "Connect wallet" (with the dropdown empty until then, any installed wallet of either kind can connect: a list of all of them when both kinds are installed, "Switch wallet" included), then "Pay"; a decorator row above the amount that alternates the USDC balance and the gas balance; three stages (confirm in wallet, payment sent, payment confirmed, the last driven by the host's status route). "Pay another way" is hidden unless `manualPayment` is set.
|
|
97
|
+
- **Pay another way (a plain transfer, e.g. from an exchange).** It offers the charge address only, under its USDC chip, with a copy button: a wallet or an exchange that sends USDC on the charge's network pays it as is. A warning above the address names the one network to choose when withdrawing and says only USDC is accepted, and the address and its copy button stay hidden until the buyer ticks "I'll send USDC on <network>". One calm line below points a buyer whose exchange cannot send on that network to withdraw to their own browser wallet first and pay with it in the modal (`manualOwnWallet`; it never names an exchange). The copy also says to make sure the full amount arrives because exchanges deduct their fee (anything short is collected and shown as a partial payment). It stays opt-in: one thing to do is still the default, and a transfer, an exchange withdrawal especially, can take longer than the charge lives, so a host that turns it on should give its charges a longer expiry.
|
|
98
|
+
- **Other tokens** (when the service offers them) show the token's own symbol with the chain subscript and a required "You pay X TOKEN" line before the button, because the amount that leaves the wallet differs from the dollar price. If the price moves between the line and the click, nothing is signed.
|
|
99
|
+
- **Vocabulary.** Buyer-facing copy never says "bridge", "route", "swap", "slippage", "quote", "authorization", "CCTP", "attestation", "Standard", "Fast" or "deliverable", and network names appear only in the dropdown subscript and, on "pay another way", in the chip, the warning and confirmation next to the address and the own-wallet line (the exchange's withdrawal screen asks for the network by name and a withdrawal on the wrong one is lost, so there the name is the one thing the buyer must get right); the word "network" itself is fine, it is what exchanges call the choice. Allowed: "Pay with USDC", "Connect wallet", "Pay $12.00", "Confirming payment...", "Payment confirmed". A unit test guards this.
|
|
100
|
+
- **Failures are controlled copy.** Text that comes back from your route, the service or a wallet (`error`, `message`, thrown errors) is never shown to the buyer: status and code map to a `copy` string (`conflict`, `expired`, `createFailed`, `network`, `service`, `unknown`, ...). "Show details" holds only an HTTP status and a code from a fixed list. The raw text reaches you through `onError` as `error.cause` (never the client secret).
|
|
101
|
+
- **Implementer settings** (server-side, per charge, never the buyer's): `speed` (`standard` default: no fee, no slippage) and `require_confirmation` (`provisional` default: the order may be fulfilled on a provisional confirmation, `final` waits for settlement). Fields: `confirmed` and `confirmation` (`pending`, `provisional`, `final`).
|
|
102
|
+
- **Quality bar:** zero runtime dependencies besides React, theming through CSS variables, light and dark, accessible (focus trap, Escape, live regions, 44 px targets), safe when storage is blocked, secrets only in memory.
|
|
103
|
+
|
|
104
|
+
## Develop
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
bun install
|
|
108
|
+
bun run dev # dev host on :3030: a stand-in storefront that mounts the widget from src (hot reload); needs the service, so prefer `bun dev` at the repo root
|
|
109
|
+
bun run typecheck
|
|
110
|
+
bun test # unit tests, server-rendered component tests, and happy-dom interaction tests
|
|
111
|
+
bun run build # dist/: one ESM file per entry (index, server, client, checkout/react with the "use client" banner and react external) + .d.ts
|
|
112
|
+
bun run test:dist # checks the built package: every exports path exists and each subpath imports (needs a build first)
|
|
113
|
+
bun pm pack --dry-run # exactly dist/, README.md and package.json ship
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`dev/` is the dev host (page, server routes and the pure logic in `dev/host.ts`); it is not part of the package (`files` ships `dist` only) and is typechecked with the rest. `bun run dev` reads `PAYMENTS_SERVICE_URL`, `PAYMENTS_API_KEY`, `PAYMENTS_MERCHANT_ID`, `PAYMENTS_WEBHOOK_SECRET` and `PORT`; `bun dev` at the repo root supplies them.
|
|
117
|
+
|
|
118
|
+
Layout: `src/index.ts` (types), `src/server.ts` (webhook utilities), `src/client.ts` (the API client) and `src/checkout/react.ts` (the widget) are the four entries; `src/entries.test.ts` enforces that none but the last reaches React or a component, that the server entry imports no `node:` module, and that no entry exports the copy table or a parser (`postCreateCharge` is exported from the client entry only). Everything else is internal. `src/lib/` holds the framework-free logic (options, pay plans, wallets, Solana, client, webhook signatures); `src/components/` holds the React components.
|
|
119
|
+
|
|
120
|
+
## Releasing
|
|
121
|
+
|
|
122
|
+
Publishing is manual and tag-driven: push a tag `sdk-vX.Y.Z` that equals the package version and points at a commit on main (a manual run of the `publish-sdk` workflow is always a dry run and stages nothing). The workflow typechecks, tests, builds, runs `bun pm pack --dry-run`, and runs `npm stage publish --access public` with the `NPM_TOKEN` secret. That only stages the version: a maintainer then approves it with 2FA (`npm stage list`, `npm stage approve <stage-id>`) and only then is it public. Nothing else can publish the package.
|
|
123
|
+
|
|
124
|
+
## Before the first stable release
|
|
125
|
+
|
|
126
|
+
- **Remove the Acoco copy.** The widget was extracted from the Acoco store template (`byoidea-v3`), which still carries its own copy. Make the template consume this package (or stop shipping the copy) so there is one implementation.
|
|
127
|
+
- **Package metadata.** Real version, license, a changelog, and a first-release smoke test in a clean Next.js app. `sideEffects` is left out on purpose: `false` or an array makes `bun build` drop the entry's re-exports and emit a broken bundle.
|
|
128
|
+
- **Bundle size.** 108 KB minified for the widget entry, 1.3 KB for `server`; check what a consumer's bundler can tree-shake.
|
|
129
|
+
- **React 18.** `peerDependencies` allow it but only React 19 is tested; add a React 18 test matrix before the first release.
|
|
130
|
+
- Cut `1.0.0` and tag it (see Releasing); alphas go out on the `next` tag only.
|
package/dist/brand.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checkout widget's brand pack: the product name for the "powered by" line and the default colours.
|
|
3
|
+
* Hosts can still override every colour through the `--sc-*` custom properties; these are the defaults.
|
|
4
|
+
*/
|
|
5
|
+
export declare const brand: Readonly<{
|
|
6
|
+
name: "Rio";
|
|
7
|
+
poweredBy: "Powered by Rio";
|
|
8
|
+
}>;
|
|
9
|
+
/** The mark, as the path of a 80x80 stroke drawing. Uses currentColor. */
|
|
10
|
+
export declare const MARK_PATH = "M 13 70 L 13 12 L 40 12 C 59 12 68 22 68 35 C 68 48 58 55 41 55 L 13 55 M 41 55 L 67 70";
|
|
11
|
+
/** Default colour tokens: light, dark when `data-appearance="dark"`, and dark for `auto` when the browser prefers it. */
|
|
12
|
+
export declare const THEME_CSS = ".sc-root{--scd-bg:#fff;--scd-surface:#f3f5f8;--scd-fg:#0b1220;--scd-muted:#5a6376;--scd-border:#dfe3e9;--scd-line:#8a92a0;--scd-accent:#315cff;--scd-on-accent:#fff;--scd-success:#15803d;--scd-danger:#b42318;--scd-warn:#a15c07;--scd-shadow:0 24px 64px rgba(15,23,42,.22),0 2px 8px rgba(15,23,42,.08);--scd-scrim:rgba(15,23,42,.42)}\n.sc-root[data-appearance=\"dark\"]{--scd-bg:#0b1220;--scd-surface:#141d31;--scd-fg:#f3f5f8;--scd-muted:#a9b3c7;--scd-border:#26314a;--scd-line:#6f7b94;--scd-accent:#7c97ff;--scd-on-accent:#0b1220;--scd-success:#4ade80;--scd-danger:#ff8f84;--scd-warn:#fbbf24;--scd-shadow:0 24px 64px rgba(0,0,0,.6),0 2px 8px rgba(0,0,0,.4);--scd-scrim:rgba(0,0,0,.62)}\n@media (prefers-color-scheme:dark){.sc-root[data-appearance=\"auto\"]{--scd-bg:#0b1220;--scd-surface:#141d31;--scd-fg:#f3f5f8;--scd-muted:#a9b3c7;--scd-border:#26314a;--scd-line:#6f7b94;--scd-accent:#7c97ff;--scd-on-accent:#0b1220;--scd-success:#4ade80;--scd-danger:#ff8f84;--scd-warn:#fbbf24;--scd-shadow:0 24px 64px rgba(0,0,0,.6),0 2px 8px rgba(0,0,0,.4);--scd-scrim:rgba(0,0,0,.62)}}";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { CheckoutWidget } from "../components/payments-checkout";
|
|
2
|
+
export type { CheckoutWidgetProps, CheckoutCopy, CheckoutState, CheckoutError, } from "../components/payments-checkout";
|
|
3
|
+
export { postCreateCharge } from "../lib/create-charge";
|
|
4
|
+
export type { PostCreateChargeOptions } from "../lib/create-charge";
|
|
5
|
+
export type { ChargeCreateResult, CreatedCharge, PlatformChargeStatus, Confirmation } from "../lib/payments";
|