mirror of
https://github.com/glitternetwork/pinme.git
synced 2026-08-28 17:42:38 +08:00
chore: update uniwebpay webhook guidance
This commit is contained in:
@@ -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,
|
||||
};
|
||||
@@ -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'],
|
||||
|
||||
@@ -231,7 +231,7 @@ await uniweb.webhooks.remove();
|
||||
await uniweb.webhooks.rollSecret(); // returns new webhookSecret when available
|
||||
```
|
||||
|
||||
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.
|
||||
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`, built from the injected `env.WORKER_URL` (e.g. `new URL("/api/pay/webhook", env.WORKER_URL)`). Prefer `env.WORKER_URL` over deriving the host from `request.url` so the callback always points at the deployed Worker regardless of which host or route handled the current request. 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.
|
||||
|
||||
## PinMe Security Rules
|
||||
|
||||
@@ -240,6 +240,7 @@ The webhook signing secret is wallet-level. PinMe injects the same `UNIWEB_WEBHO
|
||||
- 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.
|
||||
- The webhook route must be reachable without 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` from it and rely solely on signature verification for trust; otherwise callbacks get 401/403 and orders never fulfill.
|
||||
- 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.
|
||||
|
||||
@@ -247,7 +248,22 @@ The webhook signing secret is wallet-level. PinMe injects the same `UNIWEB_WEBHO
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
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, building it from the injected `env.WORKER_URL` (e.g. `new URL(WEBHOOK_PATH, env.WORKER_URL).toString()`).
|
||||
|
||||
Delivery precedence: a per-link `webhookUrl` overrides a per-product `webhookUrl`, which overrides the wallet fallback. Set `webhookUrl` on whichever resource generates the payment:
|
||||
|
||||
- Payment links: pass `webhookUrl` on `links.create`.
|
||||
- Checkout sessions: `checkout.create` takes no `webhookUrl`; its events route through the backing product, so set `webhookUrl` on the `products.create` (or reused product) that the session's price belongs to.
|
||||
- Do not rely on the wallet fallback for business events — it is shared across all of the user's projects and is only there to hold the signing secret.
|
||||
|
||||
When building the `webhookUrl`:
|
||||
|
||||
- Keep the callback path in a single shared constant (e.g. `const WEBHOOK_PATH = "/api/pay/webhook"`) used both by the router and by `webhookUrl` construction, so the path passed to UniwebPay always matches the route the Worker actually serves. A mismatch makes callbacks 404 and orders stay stuck in `pending`.
|
||||
- Use `env.WORKER_URL` as the base. It equals the project's `api_domain` platform subdomain and is the only public address available at runtime; the user's custom domain is not in `env`.
|
||||
- In non-HTTP contexts (cron triggers, queue consumers) there is no `request`, so `env.WORKER_URL` is the only usable source — fail loudly if it is 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) and use that public URL.
|
||||
- Put no secrets or trust-bearing data in the `webhookUrl`. Carry correlation such as `orderId` in `metadata`, not the query string; verify identity from the signature plus `metadata`, since a URL query can be forged.
|
||||
- `webhookUrl` must be HTTPS. `env.WORKER_URL` already is; just do not downgrade it.
|
||||
|
||||
When implementing webhooks:
|
||||
|
||||
@@ -258,6 +274,7 @@ When implementing webhooks:
|
||||
- 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.
|
||||
- Respond within UniwebPay's 10s delivery timeout, and do not put the webhook route behind a redirect — delivery does not follow redirects. Retries are sent after 5 minutes, 30 minutes, 2 hours, and 12 hours, then the event is marked failed after 6 attempts.
|
||||
|
||||
## Persistence Guidance
|
||||
|
||||
@@ -284,4 +301,4 @@ Do not store API keys, webhook secrets, or `UNIWEB_SECRET`.
|
||||
- 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.
|
||||
- Webhook code is raw-body based, verifies with `UNIWEB_WEBHOOK_SECRET`, and payment creation passes a project-specific `webhookUrl` built from `env.WORKER_URL` when events are required.
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface Env {
|
||||
UNIWEB_PAY_URL?: string;
|
||||
UNIWEB_WALLET_ID?: string;
|
||||
PROJECT_NAME?: string;
|
||||
WORKER_URL?: string;
|
||||
DB?: D1Database;
|
||||
UNIWEB_WEBHOOK_SECRET?: string;
|
||||
}
|
||||
@@ -102,8 +103,18 @@ function paymentUrl(payload: { url?: string; paymentUrl?: string }): string {
|
||||
return url;
|
||||
}
|
||||
|
||||
function projectWebhookUrl(request: Request, path = "/api/pay/webhook"): string {
|
||||
return new URL(path, request.url).toString();
|
||||
// Single source of truth for the webhook path. Use it both here and in the
|
||||
// router so the webhookUrl sent to UniwebPay always matches the served route.
|
||||
const WEBHOOK_PATH = "/api/pay/webhook";
|
||||
|
||||
function projectWebhookUrl(env: Env, request: Request, path = WEBHOOK_PATH): string {
|
||||
// Prefer the PinMe-injected WORKER_URL: it is the only in-runtime source of
|
||||
// the Worker's public host (equal to the project's api_domain platform
|
||||
// subdomain). The user's custom domain is not injected into env. Fall back to
|
||||
// request.url only for older deploys missing the binding; in non-HTTP contexts
|
||||
// (cron/queue) there is no request, so require WORKER_URL instead.
|
||||
const base = env.WORKER_URL || request.url;
|
||||
return new URL(path, base).toString();
|
||||
}
|
||||
```
|
||||
|
||||
@@ -137,7 +148,7 @@ async function createPaymentLink(request: Request, env: Env): Promise<Response>
|
||||
const amount = assertAmountCents(input.amountCents);
|
||||
const currency = normalizeCurrency(input.currency);
|
||||
const paymentMethodTypes = normalizePaymentMethods(input.paymentMethodTypes, currency);
|
||||
const webhookUrl = projectWebhookUrl(request);
|
||||
const webhookUrl = projectWebhookUrl(env, request);
|
||||
const uniweb = uniwebClient(env);
|
||||
|
||||
try {
|
||||
@@ -200,7 +211,7 @@ async function createCheckoutSession(request: Request, env: Env): Promise<Respon
|
||||
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 webhookUrl = projectWebhookUrl(env, request);
|
||||
const uniweb = uniwebClient(env);
|
||||
|
||||
try {
|
||||
@@ -334,11 +345,15 @@ async function handleUniwebWebhook(request: Request, env: Env): Promise<Response
|
||||
|
||||
Rules:
|
||||
|
||||
- Keep the webhook route outside the project's own auth. Callbacks carry only `uniweb-Signature` (plus `uniweb-Event-Id` / `uniweb-Timestamp`), never the project `API_KEY`; trust comes from `verifyWebhook`, not from an auth guard.
|
||||
- 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.
|
||||
- Respond within 10s and do not redirect the webhook route; delivery has a 10s timeout and does not follow redirects.
|
||||
|
||||
Delivery precedence: a per-link `webhookUrl` overrides a per-product `webhookUrl`, which overrides the wallet fallback. Set `webhookUrl` on the resource that creates the payment — on `links.create` for payment links, and on `products.create` for checkout sessions (`checkout.create` has no `webhookUrl`; its events route through the backing product).
|
||||
|
||||
## Minimal Router
|
||||
|
||||
@@ -347,9 +362,14 @@ export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// The webhook route must stay reachable WITHOUT the project's own auth:
|
||||
// UniwebPay callbacks carry only uniweb-Signature, never the project API_KEY.
|
||||
// Verify it here first (before any auth guard) so callbacks are not blocked.
|
||||
if (url.pathname === WEBHOOK_PATH) return handleUniwebWebhook(request, env);
|
||||
|
||||
// Any project auth guard belongs after the webhook route, on these paths.
|
||||
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 });
|
||||
},
|
||||
@@ -361,6 +381,10 @@ export default {
|
||||
- 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 hardcode the webhook host or rely solely on `request.url` for `webhookUrl`; build it from the PinMe-injected `env.WORKER_URL` and fall back to `request.url` only when the binding is absent.
|
||||
- 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 put the webhook route behind the project's API_KEY / bearer auth guard; UniwebPay callbacks would get 401/403 and orders never fulfill.
|
||||
- Do not put secrets or trust-bearing data in `webhookUrl`; carry `orderId` in `metadata` and verify via signature, since a URL query can be forged.
|
||||
- Do not set `webhookUrl` only on the wallet fallback for business events; set it per-link or per-product (checkout inherits from its product).
|
||||
- Do not mark orders paid from `successUrl` alone.
|
||||
- Do not import the SDK in browser-side code.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
type DefineMap = Record<string, string | undefined>;
|
||||
|
||||
const { createDefineMap } = require('../../build-env') as {
|
||||
createDefineMap: (env: Record<string, string | undefined>) => 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user