diff --git a/build-env.js b/build-env.js new file mode 100644 index 0000000..6b8176a --- /dev/null +++ b/build-env.js @@ -0,0 +1,28 @@ +const VALID_DEFINE_ENV_KEY = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; + +function stringifyEnvDefine(value) { + return value === undefined ? 'undefined' : JSON.stringify(value); +} + +function createDefineMap(env = process.env) { + const define = {}; + + for (const key in env) { + // Skip env vars with invalid identifier characters for esbuild defines. + if (VALID_DEFINE_ENV_KEY.test(key)) { + define[`process.env.${key}`] = stringifyEnvDefine(env[key]); + } + } + + define['process.env.IPFS_PREVIEW_URL'] = stringifyEnvDefine( + env.IPFS_PREVIEW_URL, + ); + define['process.env.SECRET_KEY'] = stringifyEnvDefine(env.SECRET_KEY); + + return define; +} + +module.exports = { + createDefineMap, + stringifyEnvDefine, +}; diff --git a/build.js b/build.js index 176b3eb..60f3414 100644 --- a/build.js +++ b/build.js @@ -1,20 +1,8 @@ require('dotenv').config(); const esbuild = require('esbuild'); +const { createDefineMap } = require('./build-env'); -const define = {}; -function stringifyEnvDefine(value) { - return value === undefined ? 'undefined' : JSON.stringify(value); -} - -for (const key in process.env) { - // Skip env vars with invalid identifier characters (e.g., Windows vars like ProgramFiles(x86)) - if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) { - define[`process.env.${key}`] = stringifyEnvDefine(process.env[key]); - } -} - -define['process.env.IPFS_PREVIEW_URL'] = stringifyEnvDefine(process.env.IPFS_PREVIEW_URL); -define['process.env.SECRET_KEY'] = stringifyEnvDefine(process.env.SECRET_KEY); +const define = createDefineMap(); esbuild.build({ entryPoints: ['bin/index.ts'], diff --git a/skills/pinme-uniwebpay/SKILL.md b/skills/pinme-uniwebpay/SKILL.md index 87e011b..67c4972 100644 --- a/skills/pinme-uniwebpay/SKILL.md +++ b/skills/pinme-uniwebpay/SKILL.md @@ -1,59 +1,40 @@ --- name: pinme-uniwebpay -description: Use when generating, modifying, or reviewing PinMe Cloudflare Worker TypeScript code that accepts payments through UniwebPay. The target app is a PinMe Worker project where PinMe injects UNIWEB_* bindings and the Worker calls UniwebPay directly with @uniwebpay/sdk. Covers Env bindings, SDK client setup, payment links, products/prices, checkout sessions, payment status reads, refunds/subscriptions/webhooks when requested, and PinMe-specific deployment/security rules. +description: Use when generating, modifying, or reviewing PinMe Worker (Cloudflare Worker TypeScript) code that accepts payments through UniwebPay — payment links, products/prices, checkout sessions, payment status reads, refunds, subscriptions, or handling UniwebPay webhooks with @uniwebpay/sdk in a PinMe project. --- -# PinMe UniwebPay +# PinMe UniwebPay Payment Integration -Build PinMe Worker payment code that uses `@uniwebpay/sdk` directly from the Worker runtime. PinMe provisions the user's UniwebPay wallet/server key and injects environment bindings during Worker deployment; Worker code should not call PinMe payment proxy routes for UniwebPay checkout. +Guides writing payment services in a PinMe Worker (Cloudflare Worker TypeScript) that call UniwebPay directly through `@uniwebpay/sdk`. -For SDK surface, request shapes, payment method rules, webhook verification, and CLI behavior, use: +Core model: PinMe provisions the UniwebPay wallet and keys per **PinMe user** (not per project) and injects `UNIWEB_*` environment bindings at Worker deploy time; Worker code calls UniwebPay **directly with the SDK** — it does not go through PinMe payment proxy routes, and it must not call the legacy VibeCash APIs. -`/Users/didi/vibecash/skills/skills/uniwebpay/SKILL.md` - -For concrete Cloudflare Worker TypeScript helpers, routes, D1 schema, and webhook examples, use: - -`references/worker-uniwebpay.md` - -The UniwebPay skill is the source of truth for how to use UniwebPay and `@uniwebpay/sdk`. This skill adds the PinMe Worker-specific contract. - -## First Steps - -1. Inspect the target Worker project: `package.json`, lockfile, `wrangler.toml`, source entrypoint, routing style, D1 migrations, and existing helper conventions. -2. If Worker code imports the SDK and `@uniwebpay/sdk` is missing, install it with the existing package manager. -3. Use the UniwebPay SDK skill above for concrete SDK methods and request payloads. -4. Keep code Cloudflare Worker-compatible. Do not add Express, Node-only body parsers, Node-only globals, or browser-side SDK imports. - -## PinMe Env Contract - -Use this `Env` shape unless the project already has a compatible one: +## Environment Binding Contract ```typescript export interface Env { - UNIWEB_SECRET: string; - UNIWEB_API_URL?: string; - UNIWEB_PAY_URL?: string; - UNIWEB_WALLET_ID?: string; - PROJECT_NAME?: string; - DB?: D1Database; - UNIWEB_WEBHOOK_SECRET?: string; + UNIWEB_SECRET: string; // PinMe-provisioned sk_server_ key (server-side only) + UNIWEB_WEBHOOK_SECRET?: string; // wallet-level whsec_, used to verify webhook signatures + UNIWEB_API_URL?: string; // UniwebPay API endpoint override (default https://apiskill.uniwebpay.com) + UNIWEB_PAY_URL?: string; // UniwebPay checkout host override (default https://skill.uniwebpay.com) + UNIWEB_WALLET_ID?: string; // user-level wallet id (wal_), diagnostics/reconciliation only + WORKER_URL?: string; // this project's public URL: https://{projectName}.{platform api domain} + PROJECT_NAME?: string; // PinMe project name + DB?: D1Database; // project D1 (if enabled) } ``` -PinMe injects these bindings after UniwebPay credentials exist and the Worker is deployed: - -- `UNIWEB_SECRET`: full-access PinMe-provisioned `sk_server_`, server-side Worker secret. -- `UNIWEB_API_URL`: UniwebPay API endpoint override. -- `UNIWEB_PAY_URL`: UniwebPay checkout/payment host override. -- `UNIWEB_WALLET_ID`: user-level wallet id for diagnostics and reconciliation. -- `UNIWEB_WEBHOOK_SECRET`: user wallet-level `whsec_` used to verify UniwebPay webhook signatures. -- `PROJECT_NAME`: PinMe project name. +Injection rules (metadata is rebuilt server-side by PinMe at deploy time; client-supplied bindings are ignored): -PinMe does not inject the full wallet secret (`sk_live_`). Do not ask the user for it and do not add it to Worker code, `wrangler.toml`, `.dev.vars`, responses, logs, D1 rows, or frontend bundles. +- The `UNIWEB_*` bindings are injected only after the user's UniwebPay credentials have been provisioned. Newly created projects are provisioned automatically and get them immediately; **existing projects must be redeployed after enabling UniwebPay or rotating keys** to pick up new bindings. +- `WORKER_URL`, `PROJECT_NAME`, `API_KEY`, `DB` and other base bindings are injected on every deploy, independent of UniwebPay. +- All projects owned by the same PinMe user share one wallet, one `sk_server_`, and one `whsec_`. +- PinMe never gives the full wallet secret (`sk_live_`) to a Worker. Do not ask the user for it, and do not put it in code, `wrangler.toml`, `.dev.vars`, responses, logs, D1, or frontend bundles. +- If `UNIWEB_SECRET` is missing at runtime, the user has not enabled UniwebPay or has not redeployed — tell the user to enable it and redeploy; never fabricate a value. -## SDK Pattern +## SDK Client -Always import and instantiate the SDK server-side: +Always instantiate on the server side (the Worker); the SDK throws when run in a browser: ```typescript import Uniweb from "@uniwebpay/sdk"; @@ -66,21 +47,40 @@ function uniwebClient(env: Env): Uniweb { } ``` -Use SDK methods from the UniwebPay SDK skill. Common PinMe Worker flows: +- The constructor's first positional argument is the key (must have an `sk_server_` or `sk_live_` prefix); the second is optional options: `{ baseUrl?, payUrl?, timeout? (default 30s), maxRetries? (default 2) }`. +- The SDK auto-retries only GET/DELETE on 429/5xx; POST/PATCH are never retried (avoids duplicate charges). +- Install `@uniwebpay/sdk` only when Worker code imports it; pick the package manager from the project's existing lockfile. + +## Choosing an Integration Path + +| Scenario | Approach | Returns | +|------|------|------| +| Fixed-amount one-time collection | `uniweb.links.create(...)` | Permanent, reusable `/p/` link (one-time payments only) | +| Stable product catalog | `products.create` + `prices.create` once, store the `priceId` | Price carries a permanent `paymentUrl` (`/buy/` link) | +| Dynamic cart/order | Reuse or create a price, then `uniweb.checkout.create(...)` | `session.url` — **one-time, expires in 24 hours** | +| Subscriptions | Recurring price + `checkout.create({ mode: "subscription" })` or `subscriptions.create` | Same as above | +| Server-side payment status checks | `payments.get / list` | Server routes only | + +Amounts are always **integer minor units** (cents). Default currency convention is `SGD` unless the app has a stronger existing convention. Do not create a new product/price on every page view — create stable catalog items once and persist the `priceId`. + +## Payment Methods and Currency Rules -- Fixed one-time collection: `uniweb.links.create(...)`, return the payment link URL. -- Stable product/price: `uniweb.products.create(...)`, `uniweb.prices.create(...)`, return/store the permanent `paymentUrl`. -- Dynamic cart/order: create or reuse a price, then call `uniweb.checkout.create(...)` and return `session.url`. -- Payment reads: `uniweb.payments.get(...)` / `list(...)` only from server routes. -- Refunds, subscription mutations, wallet/webhook setup, payouts, KYC, and bank account APIs are allowed only when the user explicitly requests that business flow and the Worker code has proper validation, persistence, and authorization. +| Method | Supported currencies | +|------|---------| +| `card` | SGD, USD, EUR, GBP, JPY, CNY, HKD, AUD, MYR, THB (minimum 10 minor units) | +| `wechat` | SGD only | +| `alipay` | SGD only | +| `paynow` | SGD only | -Amounts are integer minor units. Default currency is `SGD` unless the app has a stronger existing convention. +- The QR methods (wechat/alipay/paynow) **all support SGD only** — never generate "CNY via WeChat/Alipay" code. +- Subscriptions (recurring / `mode: "subscription"`) use `card` only. +- When `paymentMethodTypes` is omitted, the server picks sensible defaults for the currency; when passed explicitly, validate user input against the table above first. -## SDK Interface Cheatsheet +## SDK Surface Quick Reference -PinMe injects a full-access server-side `sk_server_`. Worker server code can call the UniwebPay SDK surface, but generate only the routes the app needs, and validate authorization and inputs before calling money-moving or account-management APIs. +The surface below is verified against source. All parameter fields are camelCase (`priceId`, `webhookUrl`, `startingAfter`, …); the SDK handles wire-level conversion itself. `list()` returns `{ data: T[], hasMore: boolean }`; `listAll()` is an async generator available on products, prices, payments, customers, subscriptions, and links (not on checkout or refunds). -Products: +Products (`webhookUrl` is the per-product callback override): ```typescript await uniweb.products.create({ name, description?, webhookUrl?, metadata? }); @@ -91,7 +91,7 @@ await uniweb.products.del(productId); for await (const product of uniweb.products.listAll()) {} ``` -Prices: +Prices (the returned price carries a permanent `paymentUrl`; `deactivate` takes it off sale): ```typescript await uniweb.prices.create({ @@ -112,7 +112,7 @@ await uniweb.prices.deactivate(priceId); for await (const price of uniweb.prices.listAll({ productId? })) {} ``` -Checkout sessions: +Checkout sessions (**do not accept `webhookUrl`** — events resolve through the price → product → wallet chain; the URL is one-time and expires after 24 hours): ```typescript await uniweb.checkout.create({ @@ -130,9 +130,7 @@ await uniweb.checkout.list({ limit?, startingAfter? }); await uniweb.checkout.get(checkoutSessionId); ``` -Checkout session URLs are one-time and expire after 24 hours. For subscriptions, use card-only payment methods unless the upstream SDK skill says otherwise. - -Payments: +Payments (for server-side status checks; only mark a local order paid when amount, currency, metadata, and order state all match expectations): ```typescript await uniweb.payments.create({ amount, currency, customerId?, metadata? }); @@ -144,17 +142,10 @@ await uniweb.payments.void(paymentId); for await (const payment of uniweb.payments.listAll({ status?, customerId? })) {} ``` -Use payment reads for server-side status checks. Do not mark local orders paid until amount, currency, metadata, and order state match expectations. - -Refunds: +Refunds (no list/listAll — use `payments.listRefunds`): ```typescript -await uniweb.refunds.create({ - paymentId, - amount?, // omit for full refund when appropriate - reason?, - offlineRefundFlag?, -}); +await uniweb.refunds.create({ paymentId, amount?, reason?, offlineRefundFlag? }); await uniweb.refunds.get(refundId, { gateway? }); ``` @@ -169,7 +160,7 @@ await uniweb.customers.del(customerId); for await (const customer of uniweb.customers.listAll({ email? })) {} ``` -Subscriptions: +Subscriptions (states include `trialing` / `active` / `past_due` / `unpaid` / `canceled`; update access only from verified webhooks or a trusted server-side reconciliation job): ```typescript await uniweb.subscriptions.create({ customerId, priceId, paymentMethodId?, trialPeriodDays?, metadata? }); @@ -177,13 +168,11 @@ await uniweb.subscriptions.list({ customerId?, status?, limit?, startingAfter? } await uniweb.subscriptions.get(subscriptionId); await uniweb.subscriptions.update(subscriptionId, { cancelAtPeriodEnd? }); await uniweb.subscriptions.cancel(subscriptionId); // cancel immediately -await uniweb.subscriptions.resume(subscriptionId); // undo cancel-at-period-end +await uniweb.subscriptions.resume(subscriptionId); // undo cancelAtPeriodEnd for await (const subscription of uniweb.subscriptions.listAll({ customerId?, status? })) {} ``` -Expected subscription states include `trialing`, `active`, `past_due`, `unpaid`, and `canceled`. Update access from verified webhook events or a trusted server reconciliation job. - -Payment links: +Payment links (permanent reusable `/p/` links, one-time collection only; `webhookUrl` is the per-link callback override): ```typescript await uniweb.links.create({ @@ -199,89 +188,388 @@ await uniweb.links.create({ }); await uniweb.links.list({ limit?, startingAfter? }); await uniweb.links.get(paymentLinkId); -await uniweb.links.update(paymentLinkId, { - name?, - description?, - successUrl?, - cancelUrl?, - webhookUrl?, - active?, -}); +await uniweb.links.update(paymentLinkId, { name?, description?, successUrl?, cancelUrl?, webhookUrl?, active? }); await uniweb.links.deactivate(paymentLinkId); for await (const link of uniweb.links.listAll()) {} ``` -Payment links are reusable permanent `/p/` URLs for one-time payment collection. - -Wallet: +Wallet and wallet-level webhook configuration (**danger zone**: affects the wallet shared by ALL of the user's projects): ```typescript await uniweb.wallet.current(); await uniweb.wallet.update({ merchantName?, merchantCity?, merchantCountry?, webhookUrl? }); +await uniweb.webhooks.set(url); // overwrites the wallet-level callback URL +await uniweb.webhooks.info(); +await uniweb.webhooks.remove(); +await uniweb.webhooks.rollSecret(); // rotates the shared whsec_ ``` -Wallet changes affect the user's shared Uniweb wallet. Only generate wallet mutation routes when the user explicitly asks for wallet administration, and protect those routes with project/admin authorization. +Ordinary project routes must **not** call `webhooks.set / remove / rollSecret` or `wallet.update` — they mutate the wallet callback fallback and signing secret shared across all of the user's projects. Generate them only when the user explicitly asks for wallet administration and the route has project/admin-level authorization. Same for refunds, subscription mutations, payouts, KYC, and bank account APIs: generate only when the user explicitly requests that business flow and the code has validation, persistence, and authorization. + +## Webhook Integration + +### Callback URL: set it on the link/product, pointing at this Worker + +Event delivery precedence: **per-link `webhookUrl` > per-product `webhookUrl` > wallet-level fallback**. The signing secret is always the wallet-level `whsec_` (i.e. `env.UNIWEB_WEBHOOK_SECRET`). + +PinMe sets a managed fallback callback URL on the wallet, but it exists only to obtain and preserve the signing secret — **PinMe's server discards events it receives there (204); it never forwards them to the Worker**. Business events must therefore set this project's `webhookUrl` explicitly on the resource that creates the payment: + +- Payment links: pass `webhookUrl` on `links.create`. +- Checkout sessions: `checkout.create` has no `webhookUrl` field; events route through the price's product — set `webhookUrl` on `products.create` (or on the reused product). + +Rules for building the `webhookUrl`: + +- Keep the callback path in a single constant (e.g. `const WEBHOOK_PATH = "/api/pay/webhook"`) shared by the router and the `webhookUrl` construction, so a path mismatch can't 404 the callbacks and leave orders stuck in pending. +- Use `env.WORKER_URL` as the base: `new URL(WEBHOOK_PATH, env.WORKER_URL).toString()`. It is the only public address available at runtime (the platform subdomain); the user's custom domain is not in `env`. Prefer it over `request.url` (the current request's host is not necessarily the deployed address), and non-HTTP contexts (cron/queue) have no request at all — fail loudly if it's missing rather than emitting a broken URL. +- Local dev has no `WORKER_URL`; a `request.url` fallback resolves to localhost, which UniwebPay cannot reach. To test webhooks locally, expose the Worker through a tunnel (cloudflared / ngrok). +- `webhookUrl` must be HTTPS. Put no secrets or trust-bearing data in the URL query — carry correlation like `orderId` in `metadata`, and verify identity from the signature plus `metadata` (a query string can be forged). -Webhooks: +### Verification and Handling ```typescript -await uniweb.webhooks.set(url); // returns webhook data; store webhookSecret when returned -await uniweb.webhooks.info(); // current wallet webhook URL and hasSecret -await uniweb.webhooks.remove(); -await uniweb.webhooks.rollSecret(); // returns new webhookSecret when available +import { verifyWebhook } from "@uniwebpay/sdk"; + +const rawBody = await request.text(); // read the raw body exactly once +const event = await verifyWebhook( // note: async + rawBody, + request.headers.get("uniweb-Signature") || "", // format: t=,v1= + env.UNIWEB_WEBHOOK_SECRET, +); +``` + +- `verifyWebhook(rawBody, signature, secret)` returns `Promise`; signature timestamp tolerance is ±5 minutes; it throws on failure. +- Event shape: `{ id: "evt_...", type, created, data: { object, productId?, priceId?, productName? } }`. The business object is in `event.data.object`; correlation like `orderId` is in `event.data.object.metadata`. +- Event types verified as actually delivered: `payment.succeeded` / `payment.failed` / `payment.refunded` / `payment.partially_refunded`, `refund.succeeded` / `refund.failed` / `refund.abandoned`, `checkout.session.completed` / `checkout.session.expired`, `subscription.created` / `subscription.renewed` / `subscription.past_due` / `subscription.unpaid` / `subscription.trial_ending` / `subscription.canceled`. + +Handling rules: + +- **The webhook route must bypass the project's own auth.** UniwebPay callbacks carry only `uniweb-Signature` (plus `uniweb-Event-Id` / `uniweb-Timestamp`), never the project `API_KEY`. If a global auth guard wraps all routes, exempt `WEBHOOK_PATH` — trust comes solely from signature verification. Otherwise callbacks get 401/403 and orders never fulfill. +- Return 400 for invalid signatures (so UniwebPay stops pointless retries); return 500 for temporary processing failures (so it retries). +- Enforce idempotency with `event.id` (combined with payment id / checkout session id / local order id). +- Before fulfillment, verify amount, currency, metadata, and current order state. +- Respond within 10 seconds; delivery does not follow redirects, so do not put the webhook route behind a redirect. The first delivery is immediate; on failure, retries follow at 5-minute, 30-minute, 2-hour, and 12-hour intervals, up to 6 attempts before the event is marked failed. +- Keep `UNIWEB_WEBHOOK_SECRET` optional in TypeScript — a project can exist before provisioning or redeploy; return 501 with a hint when it's missing at runtime. + +## Security Rules + +- No Uniweb secret (`UNIWEB_SECRET`, `UNIWEB_WEBHOOK_SECRET`) may appear in responses, logs, metadata, test snapshots, D1, source code, committed `wrangler.toml` / `.dev.vars`, or browser code. For local dev, use an uncommitted `.dev.vars` only. +- Do not import the SDK in browser-side code; do not use `process.env` in Cloudflare Workers — use the `env` argument. +- Do not call legacy VibeCash APIs or PinMe payment proxy routes; do not call PinMe payment APIs with `X-API-Key` for UniwebPay collection — the Worker calls UniwebPay directly through the SDK. +- `successUrl` / `cancelUrl` are UX redirects only. **Never fulfill based on a browser redirect**; grant access only after verified webhook processing (or another explicit server-side verification). +- Validate user input before SDK calls: amount (integer minor units), currency (ISO 4217 and within the payment-method constraints), quantity, product/price IDs, payment method types, order ownership, and metadata shape. + +## Persistence Guidance (D1) + +Add tables/migrations only when the project already uses D1 or the user asks for persistence. For order flows, store at least: local order id, the corresponding Uniweb link/session/payment/subscription id, amount and currency, status, timestamps, and processed webhook event ids (for idempotency). Never store secrets. + +```sql +CREATE TABLE IF NOT EXISTS orders ( + order_id TEXT PRIMARY KEY, + checkout_session_id TEXT UNIQUE, + status TEXT NOT NULL DEFAULT 'pending', + amount_cents INTEGER NOT NULL, + currency TEXT NOT NULL, + paid_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER +); + +CREATE TABLE IF NOT EXISTS payment_events ( + event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + order_id TEXT, + raw_payload TEXT NOT NULL, + created_at INTEGER NOT NULL +); ``` -The webhook signing secret is wallet-level. PinMe injects the same `UNIWEB_WEBHOOK_SECRET` into all projects owned by the same PinMe user after UniwebPay credentials and the wallet webhook secret are provisioned. Business webhook URLs do not need to be shared across projects: when creating payment links or products, pass the project Worker webhook URL as `webhookUrl`. Do not call `uniweb.webhooks.set`, `uniweb.webhooks.remove`, or `uniweb.webhooks.rollSecret` from ordinary project routes because they mutate the shared wallet fallback webhook and can rotate the shared secret. +## Worker Reference Implementation + +```typescript +import Uniweb, { verifyWebhook } from "@uniwebpay/sdk"; + +export interface Env { + UNIWEB_SECRET: string; + UNIWEB_WEBHOOK_SECRET?: string; + UNIWEB_API_URL?: string; + UNIWEB_PAY_URL?: string; + UNIWEB_WALLET_ID?: string; + WORKER_URL?: string; + PROJECT_NAME?: string; + DB?: D1Database; +} + +type PaymentMethod = "card" | "wechat" | "alipay" | "paynow"; + +const VALID_PAYMENT_METHODS = new Set(["card", "wechat", "alipay", "paynow"]); +const CARD_CURRENCIES = new Set(["SGD", "USD", "EUR", "GBP", "JPY", "CNY", "HKD", "AUD", "MYR", "THB"]); + +// Single source of truth for the webhook path: shared by the router and the +// webhookUrl construction so the address sent to UniwebPay always matches the +// route the Worker actually serves. +const WEBHOOK_PATH = "/api/pay/webhook"; + +function uniwebClient(env: Env): Uniweb { + return new Uniweb(env.UNIWEB_SECRET, { + baseUrl: env.UNIWEB_API_URL, + payUrl: env.UNIWEB_PAY_URL, + }); +} -## PinMe Security Rules +function json(data: unknown, init: ResponseInit = {}): Response { + return Response.json(data, init); +} -- Never expose `env.UNIWEB_SECRET`, `env.UNIWEB_WEBHOOK_SECRET`, or any Uniweb secret in responses, logs, metadata, test snapshots, or D1. -- Do not put `UNIWEB_SECRET` or `UNIWEB_WEBHOOK_SECRET` in source code, committed `wrangler.toml`, committed `.dev.vars`, or browser code. -- Do not call old VibeCash APIs or PinMe VibeCash proxy routes. -- Do not call PinMe payment APIs with `X-API-Key` for UniwebPay checkout. The Worker calls UniwebPay directly through the SDK. -- Treat `successUrl` and `cancelUrl` as UX only. Grant access only after verified webhook processing or another explicit server-side verification path. -- Validate user input before SDK calls: amount, currency, quantity, product/price IDs, payment method types, order ownership, and metadata shape. -- For D1-backed orders, persist a pending order before or immediately after creating the link/session, then make fulfillment idempotent. +function assertAmountCents(value: unknown): number { + const amount = Number(value); + if (!Number.isInteger(amount) || amount < 10) { + throw new Error("amountCents must be an integer minor-unit amount >= 10"); + } + return amount; +} -## Webhooks +function normalizeCurrency(value: unknown): string { + const currency = String(value || "SGD").toUpperCase(); + if (!/^[A-Z]{3}$/.test(currency)) throw new Error("currency must be an ISO 4217 code"); + return currency; +} -PinMe automatically injects `UNIWEB_WEBHOOK_SECRET` after UniwebPay credentials and the wallet webhook secret are provisioned, then the Worker is redeployed. Keep the binding optional in TypeScript because new projects can exist before UniwebPay provisioning or before redeploy. +// QR methods (wechat/alipay/paynow) support SGD only; card supports 10 currencies. +function defaultPaymentMethods(currency: string): PaymentMethod[] { + if (currency === "SGD") return ["card", "wechat", "alipay", "paynow"]; + if (CARD_CURRENCIES.has(currency)) return ["card"]; + throw new Error(`unsupported currency: ${currency}`); +} -PinMe may maintain a managed wallet-level fallback webhook URL only to obtain and preserve the signing secret. That fallback is not the project's business webhook endpoint. To route business events to the current Worker project, set a project-specific `webhookUrl` when creating UniwebPay payment links or products. +function normalizePaymentMethods(value: unknown, currency: string): PaymentMethod[] { + const requested = Array.isArray(value) && value.length > 0 ? value : defaultPaymentMethods(currency); + const methods = requested.map((m) => String(m).toLowerCase() as PaymentMethod); + for (const method of methods) { + if (!VALID_PAYMENT_METHODS.has(method)) { + throw new Error("paymentMethodTypes contains an unsupported method"); + } + if (method !== "card" && currency !== "SGD") { + throw new Error(`${method} only supports SGD payments`); + } + if (method === "card" && !CARD_CURRENCIES.has(currency)) { + throw new Error(`card does not support ${currency}`); + } + } + return Array.from(new Set(methods)); +} -When implementing webhooks: +// Prefer the PinMe-injected WORKER_URL (the project's platform subdomain, the +// only public address available at runtime). request.url is only a fallback for +// older deploys; cron/queue contexts have no request, so WORKER_URL is required. +function projectWebhookUrl(env: Env, request?: Request): string { + const base = env.WORKER_URL || request?.url; + if (!base) throw new Error("WORKER_URL binding is missing"); + return new URL(WEBHOOK_PATH, base).toString(); +} -- Use `verifyWebhook` from `@uniwebpay/sdk`. -- Read the raw request body exactly once with `request.text()`. -- Use the `uniweb-Signature` header. -- Return `400` for invalid signatures. -- Return `500` for temporary processing failures so UniwebPay retries. -- Enforce idempotency with `event.id`, payment id, checkout session id, or the app's order id. -- Before fulfillment, verify expected amount, currency, metadata, and current order state. +// ---- One-time collection: payment link ---- + +async function createPaymentLink(request: Request, env: Env): Promise { + if (request.method !== "POST") return json({ error: "method not allowed" }, { status: 405 }); + + let input: any; + try { + input = await request.json(); + } catch { + return json({ error: "invalid JSON body" }, { status: 400 }); + } + + let amount: number, currency: string, paymentMethodTypes: PaymentMethod[]; + try { + amount = assertAmountCents(input.amountCents); + currency = normalizeCurrency(input.currency); + paymentMethodTypes = normalizePaymentMethods(input.paymentMethodTypes, currency); + } catch (err) { + return json({ error: (err as Error).message }, { status: 400 }); + } + + const orderId = input.orderId || crypto.randomUUID(); + const uniweb = uniwebClient(env); + + try { + const link = await uniweb.links.create({ + amount, + currency, + name: input.name || "Payment", + description: input.description, + successUrl: input.successUrl, + cancelUrl: input.cancelUrl, + webhookUrl: projectWebhookUrl(env, request), + paymentMethodTypes, + metadata: { orderId, projectName: env.PROJECT_NAME }, + }); + return json({ orderId, linkId: link.id, url: link.url }); + } catch { + return json({ error: "failed to create payment link" }, { status: 502 }); + } +} -## Persistence Guidance +// ---- Dynamic orders: checkout session ---- +// Note: checkout.create has no webhookUrl; the callback is set on the product. +// Stable catalog items should create the product/price once and persist the +// priceId — do not create new ones on every request. + +async function createCheckoutSession(request: Request, env: Env): Promise { + if (request.method !== "POST") return json({ error: "method not allowed" }, { status: 405 }); + + let input: any; + try { + input = await request.json(); + } catch { + return json({ error: "invalid JSON body" }, { status: 400 }); + } + + let amount: number, currency: string, paymentMethodTypes: PaymentMethod[]; + try { + amount = assertAmountCents(input.amountCents); + currency = normalizeCurrency(input.currency); + paymentMethodTypes = normalizePaymentMethods(input.paymentMethodTypes, currency); + } catch (err) { + return json({ error: (err as Error).message }, { status: 400 }); + } + + const orderId = input.orderId || crypto.randomUUID(); + const quantity = Math.max(1, Math.floor(Number(input.quantity || 1))); + const uniweb = uniwebClient(env); + + try { + const product = await uniweb.products.create({ + name: input.productName, + webhookUrl: projectWebhookUrl(env, request), + metadata: { orderId, projectName: env.PROJECT_NAME }, + }); + const price = await uniweb.prices.create({ + productId: product.id, + amount, + currency, + type: "one_time", + metadata: { orderId, projectName: env.PROJECT_NAME }, + }); + const session = await uniweb.checkout.create({ + mode: "payment", + lineItems: [{ priceId: price.id, quantity }], + successUrl: input.successUrl, + cancelUrl: input.cancelUrl, + customerEmail: input.customerEmail, + paymentMethodTypes, + metadata: { orderId, projectName: env.PROJECT_NAME }, + }); + + if (env.DB) { + await env.DB.prepare( + `INSERT INTO orders(order_id, checkout_session_id, status, amount_cents, currency, created_at) + VALUES (?, ?, 'pending', ?, ?, ?) + ON CONFLICT(order_id) DO UPDATE SET checkout_session_id = excluded.checkout_session_id`, + ) + .bind(orderId, session.id, amount * quantity, currency, Math.floor(Date.now() / 1000)) + .run(); + } + + return json({ orderId, checkoutSessionId: session.id, url: session.url }); + } catch { + return json({ error: "failed to create checkout session" }, { status: 502 }); + } +} -Only add D1 tables/migrations when the project already uses D1 or the user asks for persistence. +// ---- Webhook handling ---- + +async function handleUniwebWebhook(request: Request, env: Env): Promise { + if (request.method !== "POST") return json({ error: "method not allowed" }, { status: 405 }); + if (!env.UNIWEB_WEBHOOK_SECRET) { + return json({ error: "webhook verification is not configured" }, { status: 501 }); + } + + const rawBody = await request.text(); + let event: Awaited>; + try { + event = await verifyWebhook( + rawBody, + request.headers.get("uniweb-Signature") || "", + env.UNIWEB_WEBHOOK_SECRET, + ); + } catch { + return json({ error: "invalid signature" }, { status: 400 }); + } + + try { + if (env.DB) { + const object = event.data.object as { metadata?: Record }; + const orderId = String(object?.metadata?.orderId || ""); + const now = Math.floor(Date.now() / 1000); + + // event.id idempotency: duplicate deliveries become no-ops + await env.DB.prepare( + `INSERT INTO payment_events(event_id, event_type, order_id, raw_payload, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(event_id) DO NOTHING`, + ) + .bind(event.id, event.type, orderId, rawBody, now) + .run(); + + if (event.type === "payment.succeeded" && orderId) { + // Production code should verify amount/currency/order state here before fulfilling + await env.DB.prepare( + `UPDATE orders SET status = 'paid', paid_at = ?, updated_at = ? WHERE order_id = ? AND status != 'paid'`, + ) + .bind(now, now, orderId) + .run(); + } + } + return json({ ok: true }); + } catch { + // 500 makes UniwebPay retry on the 5m/30m/2h/12h schedule + return json({ error: "processing error" }, { status: 500 }); + } +} -For order flows, store at least: +// ---- Router ---- -- local order id -- Uniweb payment link id, checkout session id, payment id, or subscription id as applicable -- amount and currency -- status -- created/updated timestamps -- processed webhook event ids for idempotency +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + // The webhook must come before any project auth guard: callbacks carry only + // uniweb-Signature, never the project API_KEY. + if (url.pathname === WEBHOOK_PATH) return handleUniwebWebhook(request, env); + + // The project's own auth guard goes after this point, on the business routes. + if (url.pathname === "/api/pay/link") return createPaymentLink(request, env); + if (url.pathname === "/api/pay/checkout") return createCheckoutSession(request, env); + + return json({ error: "not found" }, { status: 404 }); + }, +}; +``` -Do not store API keys, webhook secrets, or `UNIWEB_SECRET`. +## Common Mistakes + +| Mistake | Consequence / fix | +|------|----------| +| Webhook route blocked by the project's `API_KEY` / bearer auth guard | Callbacks get 401/403 and orders stay pending forever. Exempt `WEBHOOK_PATH`; trust comes solely from signature verification | +| Forgetting to set `webhookUrl` on the link/product | Events fall through to PinMe's managed fallback and are discarded; the Worker never sees them. Business events must point per-link/per-product at this Worker | +| Passing `webhookUrl` to `checkout.create` | The field does not exist. Set it on the backing product | +| Fulfilling on a `successUrl` redirect | Forgeable. Fulfill only from verified webhooks or server-side verification | +| Hardcoding the callback host or relying only on `request.url` | Build from `env.WORKER_URL`; `request.url` is a fallback only | +| Pairing CNY with wechat/alipay | Source limits QR methods to SGD only; CNY can only use card | +| Creating a new product/price on every request | Create stable catalog items once, persist and reuse the `priceId` | +| Calling `webhooks.set/remove/rollSecret` or `wallet.update` in ordinary business flows | Mutates/rotates the wallet callback and `whsec_` shared by ALL of the user's projects. Generate only for explicit wallet-administration requests with admin authorization | +| Using floating-point major units for amounts | Always integer minor units | +| Using `process.env` in the Worker or importing the SDK in the browser | Use the `env` argument; the SDK rejects browser environments | +| Putting `UNIWEB_SECRET` / `whsec_` in `wrangler.toml`, source, logs, or D1 | PinMe injects them at deploy time; locally use an uncommitted `.dev.vars` only | +| Reading the body more than once, or as JSON, before verification | Read with `request.text()` exactly once and pass the raw string to `verifyWebhook` | ## Finish Checklist -- `@uniwebpay/sdk` is installed only when Worker/server code imports it. -- `Env` includes the PinMe-injected `UNIWEB_*` bindings used by the code. -- SDK usage follows `/Users/didi/vibecash/skills/skills/uniwebpay/SKILL.md`. -- Code uses `new Uniweb(env.UNIWEB_SECRET, { baseUrl: env.UNIWEB_API_URL, payUrl: env.UNIWEB_PAY_URL })`. -- No VibeCash or PinMe payment proxy routes are used for UniwebPay checkout. -- No secret is exposed in source, responses, logs, D1, tests, or docs. -- Amounts and currencies are validated as integer minor-unit payments. -- Fulfillment does not rely on browser redirects. -- Webhook code is raw-body based, verifies with `UNIWEB_WEBHOOK_SECRET`, and payment creation passes a project-specific `webhookUrl` when events are required. +- [ ] `@uniwebpay/sdk` is installed only when Worker code imports it; package manager follows the lockfile. +- [ ] `Env` includes the PinMe-injected bindings the code uses; `UNIWEB_WEBHOOK_SECRET` / `WORKER_URL` stay optional and their absence is handled. +- [ ] The client is instantiated with `new Uniweb(env.UNIWEB_SECRET, { baseUrl: env.UNIWEB_API_URL, payUrl: env.UNIWEB_PAY_URL })`. +- [ ] No VibeCash or PinMe payment proxy routes are used; no secret appears in source, responses, logs, D1, tests, or docs. +- [ ] Amounts are validated integer minor units; payment methods and currencies follow the constraint table (QR methods SGD only). +- [ ] Every payment creation that needs events carries a per-link/per-product `webhookUrl` built from `env.WORKER_URL`. +- [ ] Webhook: raw body read exactly once, `verifyWebhook` verification, correct 400/500 semantics, `event.id` idempotency, route bypasses project auth, responds within 10 seconds. +- [ ] Fulfillment does not rely on browser redirects; amount, currency, metadata, and order state are verified before granting access. diff --git a/skills/pinme-uniwebpay/agents/openai.yaml b/skills/pinme-uniwebpay/agents/openai.yaml deleted file mode 100644 index fd8f973..0000000 --- a/skills/pinme-uniwebpay/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "PinMe UniwebPay" - short_description: "Worker TS code for UniwebPay SDK payments" - default_prompt: "Use $pinme-uniwebpay to add UniwebPay SDK payments to a PinMe Worker TypeScript project." diff --git a/skills/pinme-uniwebpay/references/worker-uniwebpay.md b/skills/pinme-uniwebpay/references/worker-uniwebpay.md deleted file mode 100644 index f06e25d..0000000 --- a/skills/pinme-uniwebpay/references/worker-uniwebpay.md +++ /dev/null @@ -1,366 +0,0 @@ -# Worker UniwebPay Reference - -Use this file when implementing concrete Cloudflare Worker TypeScript code for PinMe UniwebPay payments. - -## Install - -Install the SDK only when Worker code imports it. Pick the package manager from the existing lockfile. - -```bash -pnpm add @uniwebpay/sdk -npm install @uniwebpay/sdk -yarn add @uniwebpay/sdk -bun add @uniwebpay/sdk -``` - -## Core Helpers - -```typescript -import Uniweb from "@uniwebpay/sdk"; - -export interface Env { - UNIWEB_SECRET: string; - UNIWEB_API_URL?: string; - UNIWEB_PAY_URL?: string; - UNIWEB_WALLET_ID?: string; - PROJECT_NAME?: string; - DB?: D1Database; - UNIWEB_WEBHOOK_SECRET?: string; -} - -type PaymentMethod = "card" | "wechat" | "alipay" | "paynow"; - -const VALID_PAYMENT_METHODS = new Set(["card", "wechat", "alipay", "paynow"]); - -function uniwebClient(env: Env): Uniweb { - return new Uniweb(env.UNIWEB_SECRET, { - baseUrl: env.UNIWEB_API_URL, - payUrl: env.UNIWEB_PAY_URL, - }); -} - -function json(data: unknown, init: ResponseInit = {}): Response { - const headers = new Headers(init.headers); - headers.set("content-type", "application/json; charset=utf-8"); - - return Response.json(data, { - ...init, - headers, - }); -} - -async function readJSON(request: Request): Promise { - try { - return (await request.json()) as T; - } catch { - throw new Error("Invalid JSON body"); - } -} - -function assertAmountCents(value: unknown): number { - const amount = Number(value); - if (!Number.isInteger(amount) || amount < 10) { - throw new Error("amountCents must be an integer minor-unit amount >= 10"); - } - return amount; -} - -function normalizeCurrency(value: unknown): string { - const currency = String(value || "SGD").toUpperCase(); - if (!/^[A-Z]{3}$/.test(currency)) throw new Error("currency must be an ISO 4217 code"); - return currency; -} - -function defaultPaymentMethods(currency: string): PaymentMethod[] { - if (currency === "SGD") return ["card", "wechat", "alipay", "paynow"]; - if (currency === "CNY") return ["card", "wechat", "alipay"]; - return ["card"]; -} - -function normalizePaymentMethods(value: unknown, currency: string): PaymentMethod[] { - const requested = Array.isArray(value) && value.length > 0 ? value : defaultPaymentMethods(currency); - const methods = requested.map((method) => String(method).toLowerCase() as PaymentMethod); - - for (const method of methods) { - if (!VALID_PAYMENT_METHODS.has(method)) { - throw new Error("paymentMethodTypes contains an unsupported method"); - } - if (method === "paynow" && currency !== "SGD") { - throw new Error("PayNow is only supported for SGD payments"); - } - if ((method === "wechat" || method === "alipay") && !["SGD", "CNY"].includes(currency)) { - throw new Error("WeChat Pay and Alipay are only supported for SGD or CNY payments"); - } - } - - return Array.from(new Set(methods)); -} - -function paymentUrl(payload: { url?: string; paymentUrl?: string }): string { - const url = payload.url || payload.paymentUrl; - if (!url) throw new Error("UniwebPay response missing payment URL"); - return url; -} - -function projectWebhookUrl(request: Request, path = "/api/pay/webhook"): string { - return new URL(path, request.url).toString(); -} -``` - -## Payment Link Route - -Use this for a simple one-time payment link. It is the lightest Worker integration. - -```typescript -type CreatePaymentLinkBody = { - orderId?: string; - amountCents: number; - currency?: string; - name?: string; - description?: string; - successUrl?: string; - cancelUrl?: string; - paymentMethodTypes?: PaymentMethod[]; -}; - -async function createPaymentLink(request: Request, env: Env): Promise { - if (request.method !== "POST") return json({ error: "method not allowed" }, { status: 405 }); - - let input: CreatePaymentLinkBody; - try { - input = await readJSON(request); - } catch (err) { - return json({ error: (err as Error).message }, { status: 400 }); - } - - const orderId = input.orderId || crypto.randomUUID(); - const amount = assertAmountCents(input.amountCents); - const currency = normalizeCurrency(input.currency); - const paymentMethodTypes = normalizePaymentMethods(input.paymentMethodTypes, currency); - const webhookUrl = projectWebhookUrl(request); - const uniweb = uniwebClient(env); - - try { - const link = await uniweb.links.create({ - amount, - currency, - name: input.name || "Payment", - description: input.description, - successUrl: input.successUrl, - cancelUrl: input.cancelUrl, - webhookUrl, - paymentMethodTypes, - metadata: { - orderId, - projectName: env.PROJECT_NAME, - }, - }); - - return json({ - orderId, - linkId: link.id, - url: paymentUrl(link as { url?: string; paymentUrl?: string }), - walletId: env.UNIWEB_WALLET_ID, - }); - } catch (err) { - return json({ error: "failed to create payment link" }, { status: 502 }); - } -} -``` - -## Checkout Session Route - -Use this when checkout is dynamic and should expire after 24 hours. Checkout sessions need a price. For truly stable catalog items, create products/prices once and store the `priceId`; do not create a new product/price for every page view. - -```typescript -type CreateCheckoutBody = { - orderId?: string; - productName: string; - amountCents: number; - currency?: string; - successUrl: string; - cancelUrl: string; - customerEmail?: string; - quantity?: number; - paymentMethodTypes?: PaymentMethod[]; -}; - -async function createCheckoutSession(request: Request, env: Env): Promise { - if (request.method !== "POST") return json({ error: "method not allowed" }, { status: 405 }); - - let input: CreateCheckoutBody; - try { - input = await readJSON(request); - } catch (err) { - return json({ error: (err as Error).message }, { status: 400 }); - } - - const orderId = input.orderId || crypto.randomUUID(); - const amount = assertAmountCents(input.amountCents); - const currency = normalizeCurrency(input.currency); - const quantity = Math.max(1, Math.floor(Number(input.quantity || 1))); - const paymentMethodTypes = normalizePaymentMethods(input.paymentMethodTypes, currency); - const webhookUrl = projectWebhookUrl(request); - const uniweb = uniwebClient(env); - - try { - const product = await uniweb.products.create({ - name: input.productName, - webhookUrl, - metadata: { orderId, projectName: env.PROJECT_NAME }, - }); - - const price = await uniweb.prices.create({ - productId: product.id, - amount, - currency, - type: "one_time", - metadata: { orderId, projectName: env.PROJECT_NAME }, - }); - - const session = await uniweb.checkout.create({ - mode: "payment", - lineItems: [{ priceId: price.id, quantity }], - successUrl: input.successUrl, - cancelUrl: input.cancelUrl, - customerEmail: input.customerEmail, - paymentMethodTypes, - metadata: { orderId, projectName: env.PROJECT_NAME }, - }); - - if (env.DB) { - await env.DB.prepare( - `INSERT INTO orders(order_id, checkout_session_id, status, amount_cents, currency, created_at) - VALUES (?, ?, 'pending', ?, ?, ?) - ON CONFLICT(order_id) DO UPDATE SET checkout_session_id = excluded.checkout_session_id`, - ) - .bind(orderId, session.id, amount * quantity, currency, Math.floor(Date.now() / 1000)) - .run(); - } - - return json({ - orderId, - checkoutSessionId: session.id, - url: paymentUrl(session as { url?: string; paymentUrl?: string }), - walletId: env.UNIWEB_WALLET_ID, - }); - } catch (err) { - return json({ error: "failed to create checkout session" }, { status: 502 }); - } -} -``` - -## D1 Schema - -Use only if the target Worker already has D1 or the user asks for persistence. - -```sql -CREATE TABLE IF NOT EXISTS orders ( - order_id TEXT PRIMARY KEY, - checkout_session_id TEXT UNIQUE, - status TEXT NOT NULL DEFAULT 'pending', - amount_cents INTEGER NOT NULL, - currency TEXT NOT NULL, - paid_at INTEGER, - created_at INTEGER NOT NULL, - updated_at INTEGER -); - -CREATE TABLE IF NOT EXISTS payment_events ( - event_id TEXT PRIMARY KEY, - event_type TEXT NOT NULL, - order_id TEXT, - raw_payload TEXT NOT NULL, - created_at INTEGER NOT NULL -); -``` - -## Optional Webhook Route - -PinMe injects `UNIWEB_WEBHOOK_SECRET` after UniwebPay credentials and the wallet webhook secret are provisioned, then the Worker is redeployed. Keep the Env binding optional for projects that have not been provisioned or redeployed yet, but generate verified webhook handling by default when payment fulfillment depends on webhooks. - -Add the verifier import next to the `Uniweb` import at the top of the Worker file: - -```typescript -import { verifyWebhook } from "@uniwebpay/sdk"; -``` - -```typescript -async function handleUniwebWebhook(request: Request, env: Env): Promise { - if (request.method !== "POST") return json({ error: "method not allowed" }, { status: 405 }); - if (!env.UNIWEB_WEBHOOK_SECRET) { - return json({ error: "webhook verification is not configured" }, { status: 501 }); - } - - const rawBody = await request.text(); - let event: any; - - try { - event = await verifyWebhook( - rawBody, - request.headers.get("uniweb-Signature") || "", - env.UNIWEB_WEBHOOK_SECRET, - ); - } catch { - return json({ error: "invalid signature" }, { status: 400 }); - } - - try { - if (env.DB) { - const orderId = String(event?.data?.object?.metadata?.orderId || ""); - await env.DB.prepare( - `INSERT INTO payment_events(event_id, event_type, order_id, raw_payload, created_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(event_id) DO NOTHING`, - ) - .bind(event.id, event.type, orderId, rawBody, Math.floor(Date.now() / 1000)) - .run(); - - if (event.type === "payment.succeeded" && orderId) { - await env.DB.prepare( - `UPDATE orders SET status = 'paid', paid_at = ?, updated_at = ? WHERE order_id = ? AND status != 'paid'`, - ) - .bind(Math.floor(Date.now() / 1000), Math.floor(Date.now() / 1000), orderId) - .run(); - } - } - - return json({ ok: true }); - } catch { - return json({ error: "processing error" }, { status: 500 }); - } -} -``` - -Rules: - -- Read the raw body exactly once before verification. -- Return 400 for bad signatures so UniwebPay does not retry impossible deliveries. -- Return 500 for temporary processing failures so UniwebPay can retry. -- Make event handling idempotent with `event.id`. -- Check amount, currency, metadata, and current order state before granting access. - -## Minimal Router - -```typescript -export default { - async fetch(request: Request, env: Env): Promise { - const url = new URL(request.url); - - if (url.pathname === "/api/pay/link") return createPaymentLink(request, env); - if (url.pathname === "/api/pay/checkout") return createCheckoutSession(request, env); - if (url.pathname === "/api/pay/webhook") return handleUniwebWebhook(request, env); - - return json({ error: "not found" }, { status: 404 }); - }, -}; -``` - -## Common Mistakes - -- Do not use `process.env` in Cloudflare Workers; use the `env` argument. -- Do not put `UNIWEB_SECRET` or `UNIWEB_WEBHOOK_SECRET` in `wrangler.toml`. PinMe injects them during deployment. For local dev, use an uncommitted `.dev.vars` only. -- Do not omit `webhookUrl` on payment links or products when the project expects webhook-driven fulfillment. -- Do not call `uniweb.webhooks.set`, `uniweb.webhooks.remove`, `uniweb.webhooks.rollSecret`, `uniweb.wallet.update`, refunds, payouts, or subscription mutation APIs unless the user explicitly asks for that flow and the route has project/admin authorization. -- Do not mark orders paid from `successUrl` alone. -- Do not import the SDK in browser-side code. diff --git a/test/unit/buildEnv.test.ts b/test/unit/buildEnv.test.ts new file mode 100644 index 0000000..ae77960 --- /dev/null +++ b/test/unit/buildEnv.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'vitest'; + +type DefineMap = Record; + +const { createDefineMap } = require('../../build-env') as { + createDefineMap: (env: Record) => DefineMap; +}; + +describe('build env defines', () => { + test('filters env keys that are not valid define identifiers', () => { + const define = createDefineMap({ + PINME_API_BASE: 'https://pinme.dev/api/v4', + 'npm_package_bin_pinme-agent': './dist/index.js', + SECRET_KEY: 'dummy', + }); + + expect(define['process.env.PINME_API_BASE']).toBe( + JSON.stringify('https://pinme.dev/api/v4'), + ); + expect(define['process.env.SECRET_KEY']).toBe(JSON.stringify('dummy')); + expect(define['process.env.npm_package_bin_pinme-agent']).toBeUndefined(); + }); +});