glyphpay 1.0.0 → 1.0.1
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 +21 -7
- package/dist/browser.d.ts +3 -1
- package/dist/browser.js +13 -4
- package/dist/cli.d.ts +11 -1
- package/dist/cli.js +165 -27
- package/dist/security.d.ts +6 -0
- package/dist/security.js +16 -0
- package/dist/server.d.ts +22 -3
- package/dist/server.js +162 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,6 +43,8 @@ GLYPHPAY_CALLBACK_SECRET=dgcb_...
|
|
|
43
43
|
- `--no-open`, `--base-url` and `--timeout` help scripts and local testing.
|
|
44
44
|
- Codes expire after ten minutes. Approving again rotates the token: the previous token stops working immediately.
|
|
45
45
|
- Pairing is disabled while the platform is in live mode.
|
|
46
|
+
- **Only approve a code you generated in your own terminal.** Glyph Pay never calls, emails, or messages you to approve a pairing code. Check the webhook domain before approving; approval rotates the credential and can replace the old webhook.
|
|
47
|
+
- `--base-url` and `GLYPHPAY_API_URL` must use HTTPS; loopback HTTP is accepted only for local development. A non-default origin is printed.
|
|
46
48
|
|
|
47
49
|
The browser never receives the credential. Keep the token in server environment variables and never prefix it with `NEXT_PUBLIC_`, `VITE_` or `PUBLIC_`.
|
|
48
50
|
|
|
@@ -55,7 +57,7 @@ npx glyphpay rotate
|
|
|
55
57
|
npx glyphpay disconnect
|
|
56
58
|
```
|
|
57
59
|
|
|
58
|
-
`doctor` verifies the local env file, checks for credentials leaked into public-prefixed variables, validates the token with Glyph Pay, checks Stripe charge readiness and the registered webhook, and optionally sends a signed test event:
|
|
60
|
+
`doctor` verifies the local env file, warns when it is not ignored by git, checks for credentials leaked into public-prefixed variables, validates the token with Glyph Pay, checks Stripe charge readiness and the registered webhook, and optionally sends a signed test event:
|
|
59
61
|
|
|
60
62
|
```sh
|
|
61
63
|
npx glyphpay doctor --send-test-event
|
|
@@ -72,7 +74,7 @@ npx glyphpay deliveries
|
|
|
72
74
|
npx glyphpay deliveries --retry evt_...
|
|
73
75
|
```
|
|
74
76
|
|
|
75
|
-
`deliveries` pages through callback outbox records for the credential's client and environment: event type, delivery state, attempt count, timestamp, and the last error. Filter with `--state pending|delivered|failed`, control the page with `--limit 1..100`, and pass the returned `--cursor` for the next page. A retry sends the
|
|
77
|
+
`deliveries` pages through callback outbox records for the credential's client and environment: event type, delivery state, attempt count, timestamp, and the last error. Filter with `--state pending|delivered|failed`, control the page with `--limit 1..100`, and pass the returned `--cursor` for the next page. A retry re-sends the stored event body with a fresh signature and timestamp, then persists the result. Retry is one attempt; fix the endpoint first. The admin **Connections** tab exposes the same filtering, pagination, and retry operations.
|
|
76
78
|
|
|
77
79
|
## Scaffold a site
|
|
78
80
|
|
|
@@ -95,7 +97,7 @@ It refuses to overwrite existing files unless you pass `--force`; use `--dry-run
|
|
|
95
97
|
|
|
96
98
|
1. **Admin → Clients → (client)**: make sure Stripe onboarding is complete (charges enabled).
|
|
97
99
|
2. **Issue a service credential** (test first). Copy the `dgsvc_test_…` token. It is shown only once.
|
|
98
|
-
3. **Set the callback URL** to `https://<client-site>/api/glyphpay/webhook`. Copy the signing secret. It is also shown only once.
|
|
100
|
+
3. **Set the callback URL** to `https://<client-site>/api/glyphpay/webhook`. Check the domain before saving; saving replaces the previous callback. Copy the signing secret. It is also shown only once.
|
|
99
101
|
|
|
100
102
|
## Add to the client site
|
|
101
103
|
|
|
@@ -120,16 +122,23 @@ export const glyphpay = glyphPay({
|
|
|
120
122
|
callbackSecret: process.env.GLYPHPAY_CALLBACK_SECRET,
|
|
121
123
|
|
|
122
124
|
// Price the order from YOUR data. Never trust an amount sent by the browser.
|
|
123
|
-
async priceOrder(body) {
|
|
124
|
-
const
|
|
125
|
+
async priceOrder(body, req) {
|
|
126
|
+
const cartId = (body as { cartId?: string }).cartId;
|
|
127
|
+
// Load from the shopper's session, or verify ownership; never fetch by a guessable id alone.
|
|
128
|
+
const cart = cartId ? await db.carts.getForShopper(cartId, req) : null;
|
|
125
129
|
if (!cart?.items.length) throw new CheckoutError("Your cart is empty.");
|
|
126
130
|
// orderId must change whenever the total changes, or GlyphPay returns 409.
|
|
127
131
|
return { orderId: `${cart.id}-v${cart.version}`, amountCents: cart.totalCents, description: `Order ${cart.id}` };
|
|
128
132
|
},
|
|
129
133
|
|
|
134
|
+
// Optional fast path; a unique insert in onEvent also works.
|
|
135
|
+
seen: (eventId) => db.processedEvents.has(eventId),
|
|
136
|
+
|
|
130
137
|
// Signed and verified. At-least-once delivery: dedupe on event.id. Throw to have it retried.
|
|
131
138
|
async onEvent(event) {
|
|
139
|
+
if (await db.processedEvents.has(event.id)) return;
|
|
132
140
|
if (event.type === "payment.succeeded") await db.orders.markPaid(event.data.orderId, event.id);
|
|
141
|
+
await db.processedEvents.add(event.id);
|
|
133
142
|
},
|
|
134
143
|
});
|
|
135
144
|
```
|
|
@@ -148,6 +157,8 @@ import { glyphpay } from "@/lib/glyphpay";
|
|
|
148
157
|
export const POST = glyphpay.webhook;
|
|
149
158
|
```
|
|
150
159
|
|
|
160
|
+
The checkout route requires `application/json`, checks the request `Origin`, caps the body at 1 MB, and exposes `beforeCheckout(req)` for rate limiting or a bot challenge. Put rate limiting in your host/proxy or that hook, and enable Stripe Radar for the connected account. The webhook route also caps bodies at 1 MB, accepts multiple `v1` signatures during secret rotation, rejects a `livemode` mismatch, and calls `seen` before `onEvent`.
|
|
161
|
+
|
|
151
162
|
Checkout page:
|
|
152
163
|
|
|
153
164
|
```tsx
|
|
@@ -156,6 +167,8 @@ import { GlyphPayCheckout } from "glyphpay/react";
|
|
|
156
167
|
<GlyphPayCheckout order={{ cartId }} returnUrl={`/thank-you?cart=${cartId}`} appearance={{ theme: "stripe" }} />
|
|
157
168
|
```
|
|
158
169
|
|
|
170
|
+
`returnUrl` must stay on the site's origin unless you set `allowCrossOriginReturn`. A redirect is never payment proof; verify the payment on the server.
|
|
171
|
+
|
|
159
172
|
Style the button with `.glyphpay button { … }`. Errors appear in `.glyphpay [role=alert]`.
|
|
160
173
|
|
|
161
174
|
Without React:
|
|
@@ -164,7 +177,8 @@ Without React:
|
|
|
164
177
|
<div id="pay"></div>
|
|
165
178
|
<script type="module">
|
|
166
179
|
import { mountCheckout } from "glyphpay/browser";
|
|
167
|
-
|
|
180
|
+
// cartId comes from the shopper's server-rendered session, not a URL parameter.
|
|
181
|
+
mountCheckout(document.getElementById("pay"), { order: { cartId: "session-cart-id" }, returnUrl: "/thank-you" });
|
|
168
182
|
</script>
|
|
169
183
|
```
|
|
170
184
|
|
|
@@ -175,7 +189,7 @@ The route handlers take a standard `Request` and return a `Response`, so the sam
|
|
|
175
189
|
1. The component POSTs `order` to `/api/glyphpay/checkout`. `priceOrder` sets the amount, and the site's server calls GlyphPay with the token.
|
|
176
190
|
2. The browser gets back only `clientSecret`, `publishableKey`, and `stripeAccount`, then mounts Stripe's Payment Element.
|
|
177
191
|
3. The shopper pays, and Stripe redirects to `returnUrl`. **The redirect doesn't prove payment.**
|
|
178
|
-
4. GlyphPay POSTs `payment.succeeded` to `/api/glyphpay/webhook`, and `onEvent` marks the order paid. The thank-you page reads the order status from your database, or calls `glyphpay.getPayment(paymentId)
|
|
192
|
+
4. GlyphPay POSTs `payment.succeeded` to `/api/glyphpay/webhook`, and `onEvent` marks the order paid. The thank-you page reads the order status from your database, or calls `glyphpay.getPayment(paymentId)` after checking that the payment belongs to that shopper.
|
|
179
193
|
|
|
180
194
|
Refunds run server-side:
|
|
181
195
|
|
package/dist/browser.d.ts
CHANGED
|
@@ -6,8 +6,10 @@ import { type Appearance } from "@stripe/stripe-js";
|
|
|
6
6
|
export interface CheckoutOptions {
|
|
7
7
|
/** JSON posted to your checkout route, e.g. `{ cartId }`. Your server prices it; never send a trusted amount. */
|
|
8
8
|
order: unknown;
|
|
9
|
-
/** Where Stripe sends the shopper after paying.
|
|
9
|
+
/** Where Stripe sends the shopper after paying. Must stay on this site unless `allowCrossOriginReturn` is set. */
|
|
10
10
|
returnUrl: string;
|
|
11
|
+
/** Allow an absolute return URL on another origin. Off by default. */
|
|
12
|
+
allowCrossOriginReturn?: boolean;
|
|
11
13
|
/** Your site's checkout route. Default `/api/glyphpay/checkout`. */
|
|
12
14
|
endpoint?: string;
|
|
13
15
|
/** Stripe Elements appearance (theme, colors, fonts). */
|
package/dist/browser.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Talks only to the site's own checkout route; the service credential never reaches the browser.
|
|
4
4
|
*/
|
|
5
5
|
import { loadStripe } from "@stripe/stripe-js";
|
|
6
|
+
import { resolveReturnUrl } from "./security.js";
|
|
6
7
|
/**
|
|
7
8
|
* Renders `<form class="glyphpay">` (Stripe Payment Element + submit button + `role=alert` message)
|
|
8
9
|
* into `container`. Style it with `.glyphpay button { … }`. Returns an unmount function.
|
|
@@ -42,10 +43,18 @@ export function mountCheckout(container, options) {
|
|
|
42
43
|
event.preventDefault();
|
|
43
44
|
button.disabled = true;
|
|
44
45
|
message.textContent = "";
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
try {
|
|
47
|
+
// On success Stripe always redirects to returnUrl (adds payment_intent & redirect_status params).
|
|
48
|
+
const returnUrl = resolveReturnUrl(options.returnUrl, location.href, !!options.allowCrossOriginReturn);
|
|
49
|
+
const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: returnUrl } });
|
|
50
|
+
message.textContent = error.message ?? "Payment failed. Please try again.";
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
message.textContent = error instanceof Error ? error.message : "Payment failed. Please try again.";
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
button.disabled = false;
|
|
57
|
+
}
|
|
49
58
|
});
|
|
50
59
|
})().catch((error) => { message.textContent = error.message; });
|
|
51
60
|
return () => { unmounted = true; form.remove(); };
|
package/dist/cli.d.ts
CHANGED
|
@@ -99,6 +99,16 @@ interface ParsedCommand {
|
|
|
99
99
|
}
|
|
100
100
|
export declare function parseArgs(argv: string[]): ParsedCommand;
|
|
101
101
|
export declare function helpText(): string;
|
|
102
|
+
/** Strip ANSI escapes and control characters from server-controlled text before printing it. */
|
|
103
|
+
export declare function sanitizeTerminal(value: string): string;
|
|
104
|
+
/** Validate and normalize the API origin. HTTPS is required outside loopback development. */
|
|
105
|
+
export declare function normalizeApiBaseUrl(raw: string): string;
|
|
106
|
+
/** True when a browser URL is https, or http on loopback. */
|
|
107
|
+
export declare function isSafeBrowserUrl(raw: string): boolean;
|
|
108
|
+
/** True when a callback URL is public HTTPS (no credentials, IP literals, or internal names). */
|
|
109
|
+
export declare function isPublicHttpsUrl(raw: string): boolean;
|
|
110
|
+
/** Doctor may probe a public HTTPS callback, or loopback HTTP for local testing. */
|
|
111
|
+
export declare function isProbeableCallbackUrl(raw: string): boolean;
|
|
102
112
|
/** `shop.example.com` -> `https://shop.example.com/api/glyphpay/webhook`. */
|
|
103
113
|
export declare function toCallbackUrl(raw: string): string;
|
|
104
114
|
/**
|
|
@@ -115,7 +125,7 @@ export declare function removeEnvKeys(contents: string, keys: string[]): string;
|
|
|
115
125
|
export declare function readEnvValue(contents: string, key: string): string | null;
|
|
116
126
|
/** Public-prefixed variables must never contain a Glyph Pay credential. */
|
|
117
127
|
export declare function findPublicCredentialKeys(contents: string): string[];
|
|
118
|
-
export declare function openBrowser(
|
|
128
|
+
export declare function openBrowser(rawUrl: string): boolean;
|
|
119
129
|
export declare function runConnect(options?: CliOptions, mode?: "connect" | "rotate"): Promise<ConnectResult>;
|
|
120
130
|
/**
|
|
121
131
|
* Re-pair the site. If the current env token is still valid, reuse the
|
package/dist/cli.js
CHANGED
|
@@ -14,19 +14,21 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Zero runtime dependencies: Node 18+ built-ins and global fetch only.
|
|
16
16
|
*/
|
|
17
|
-
import { spawn } from "node:child_process";
|
|
17
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
18
18
|
import { createHmac, randomBytes } from "node:crypto";
|
|
19
19
|
import { access, chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
20
20
|
import { dirname, join, relative, resolve } from "node:path";
|
|
21
21
|
import { createInterface } from "node:readline/promises";
|
|
22
22
|
import { pathToFileURL } from "node:url";
|
|
23
|
-
const VERSION = "1.0.
|
|
23
|
+
const VERSION = "1.0.1";
|
|
24
24
|
const DEFAULT_BASE_URL = "https://designglyph.web.app";
|
|
25
25
|
const DEFAULT_ENV_FILE = ".env.local";
|
|
26
26
|
const DEFAULT_TIMEOUT_SECONDS = 600;
|
|
27
27
|
const TOKEN_KEY = "GLYPHPAY_TOKEN";
|
|
28
28
|
const CALLBACK_SECRET_KEY = "GLYPHPAY_CALLBACK_SECRET";
|
|
29
29
|
const WEBHOOK_PATH = "/api/glyphpay/webhook";
|
|
30
|
+
const TOKEN_PATTERN = /^dgsvc_(test|live)_[A-Za-z0-9_-]{1,128}_[A-Za-z0-9_-]{43}$/;
|
|
31
|
+
const CALLBACK_SECRET_PATTERN = /^dgcb_[A-Za-z0-9_-]{20,}$/;
|
|
30
32
|
export class CliUsageError extends Error {
|
|
31
33
|
constructor(message) {
|
|
32
34
|
super(message);
|
|
@@ -291,6 +293,80 @@ approves this machine. The service credential goes only to this terminal.`;
|
|
|
291
293
|
/* ------------------------------------------------------------------ */
|
|
292
294
|
/* URL and env-file helpers */
|
|
293
295
|
/* ------------------------------------------------------------------ */
|
|
296
|
+
function isLoopbackHost(hostname) {
|
|
297
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
298
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
299
|
+
}
|
|
300
|
+
/** Strip ANSI escapes and control characters from server-controlled text before printing it. */
|
|
301
|
+
export function sanitizeTerminal(value) {
|
|
302
|
+
return value
|
|
303
|
+
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
304
|
+
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "")
|
|
305
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
306
|
+
.replace(/\s+/g, " ")
|
|
307
|
+
.trim();
|
|
308
|
+
}
|
|
309
|
+
/** Validate and normalize the API origin. HTTPS is required outside loopback development. */
|
|
310
|
+
export function normalizeApiBaseUrl(raw) {
|
|
311
|
+
let url;
|
|
312
|
+
try {
|
|
313
|
+
url = new URL(raw);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
throw new CliUsageError("--base-url is not a valid URL.");
|
|
317
|
+
}
|
|
318
|
+
if (url.username || url.password)
|
|
319
|
+
throw new CliUsageError("--base-url must not contain credentials.");
|
|
320
|
+
if (url.protocol !== "https:" && !(isLoopbackHost(url.hostname) && url.protocol === "http:")) {
|
|
321
|
+
throw new CliUsageError("--base-url must use https:// (http:// is allowed only for localhost).");
|
|
322
|
+
}
|
|
323
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
324
|
+
}
|
|
325
|
+
/** True when a browser URL is https, or http on loopback. */
|
|
326
|
+
export function isSafeBrowserUrl(raw) {
|
|
327
|
+
try {
|
|
328
|
+
const url = new URL(raw);
|
|
329
|
+
if (url.protocol !== "https:" && !(isLoopbackHost(url.hostname) && url.protocol === "http:"))
|
|
330
|
+
return false;
|
|
331
|
+
return !url.username && !url.password;
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/** True when a callback URL is public HTTPS (no credentials, IP literals, or internal names). */
|
|
338
|
+
export function isPublicHttpsUrl(raw) {
|
|
339
|
+
try {
|
|
340
|
+
const url = new URL(raw);
|
|
341
|
+
if (url.protocol !== "https:" || url.username || url.password)
|
|
342
|
+
return false;
|
|
343
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
344
|
+
if (!host || isLoopbackHost(host) || !host.includes("."))
|
|
345
|
+
return false;
|
|
346
|
+
if (/\.(localhost|local|internal|lan|home|corp)$/.test(host))
|
|
347
|
+
return false;
|
|
348
|
+
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":"))
|
|
349
|
+
return false;
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/** Doctor may probe a public HTTPS callback, or loopback HTTP for local testing. */
|
|
357
|
+
export function isProbeableCallbackUrl(raw) {
|
|
358
|
+
try {
|
|
359
|
+
const url = new URL(raw);
|
|
360
|
+
if (url.username || url.password)
|
|
361
|
+
return false;
|
|
362
|
+
if (url.protocol === "https:")
|
|
363
|
+
return isPublicHttpsUrl(raw);
|
|
364
|
+
return url.protocol === "http:" && isLoopbackHost(url.hostname);
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
294
370
|
/** `shop.example.com` -> `https://shop.example.com/api/glyphpay/webhook`. */
|
|
295
371
|
export function toCallbackUrl(raw) {
|
|
296
372
|
let value = raw.trim();
|
|
@@ -305,11 +381,26 @@ export function toCallbackUrl(raw) {
|
|
|
305
381
|
catch {
|
|
306
382
|
throw new CliUsageError(`"${raw}" is not a valid URL.`);
|
|
307
383
|
}
|
|
308
|
-
|
|
309
|
-
|
|
384
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
385
|
+
const loopback = isLoopbackHost(host);
|
|
386
|
+
if (url.protocol !== "https:" && !(loopback && url.protocol === "http:")) {
|
|
387
|
+
throw new CliUsageError("The site URL must use https:// (http:// is allowed only for localhost).");
|
|
310
388
|
}
|
|
311
389
|
if (url.username || url.password)
|
|
312
390
|
throw new CliUsageError("The site URL must not contain a username or password.");
|
|
391
|
+
if (url.protocol === "https:" && url.port && url.port !== "443") {
|
|
392
|
+
throw new CliUsageError("The site URL must use the default https port.");
|
|
393
|
+
}
|
|
394
|
+
if (!loopback && !host.includes("."))
|
|
395
|
+
throw new CliUsageError("The site URL must use a public hostname.");
|
|
396
|
+
if (!loopback && /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) {
|
|
397
|
+
throw new CliUsageError("The site URL must use a public hostname, not an IP address.");
|
|
398
|
+
}
|
|
399
|
+
if (!loopback && host.includes(":"))
|
|
400
|
+
throw new CliUsageError("The site URL must use a public hostname, not an IP address.");
|
|
401
|
+
if (!loopback && /\.(localhost|local|internal|lan|home|corp)$/.test(host)) {
|
|
402
|
+
throw new CliUsageError("The site URL must use a public hostname.");
|
|
403
|
+
}
|
|
313
404
|
if (url.pathname === "/" || url.pathname === "")
|
|
314
405
|
url.pathname = WEBHOOK_PATH;
|
|
315
406
|
url.hash = "";
|
|
@@ -321,6 +412,13 @@ export function toCallbackUrl(raw) {
|
|
|
321
412
|
* so the file keeps a single source of truth.
|
|
322
413
|
*/
|
|
323
414
|
export function upsertEnv(contents, values) {
|
|
415
|
+
for (const [key, value] of Object.entries(values)) {
|
|
416
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
417
|
+
throw new CliUsageError(`Invalid environment key: ${key}`);
|
|
418
|
+
if (/[\r\n\0]/.test(value)) {
|
|
419
|
+
throw new CliUsageError(`Refusing to write ${key}: the value contains a line break or NUL byte.`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
324
422
|
const eol = contents.includes("\r\n") ? "\r\n" : "\n";
|
|
325
423
|
const lines = contents.length
|
|
326
424
|
? contents.replace(/\r\n/g, "\n").replace(/\n+$/, "").split("\n")
|
|
@@ -432,11 +530,14 @@ async function directoryExists(path) {
|
|
|
432
530
|
/* ------------------------------------------------------------------ */
|
|
433
531
|
/* Browser and API */
|
|
434
532
|
/* ------------------------------------------------------------------ */
|
|
435
|
-
export function openBrowser(
|
|
436
|
-
|
|
437
|
-
|
|
533
|
+
export function openBrowser(rawUrl) {
|
|
534
|
+
if (!isSafeBrowserUrl(rawUrl))
|
|
535
|
+
return false;
|
|
536
|
+
const url = new URL(rawUrl).toString();
|
|
537
|
+
const command = process.platform === "win32" ? "rundll32" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
538
|
+
const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
|
438
539
|
try {
|
|
439
|
-
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
540
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
440
541
|
child.on("error", () => undefined);
|
|
441
542
|
child.unref();
|
|
442
543
|
return true;
|
|
@@ -471,7 +572,12 @@ async function api(baseUrl, path, body, token) {
|
|
|
471
572
|
return payload.data;
|
|
472
573
|
}
|
|
473
574
|
function baseUrlFor(options) {
|
|
474
|
-
|
|
575
|
+
const raw = options.baseUrl?.trim() || process.env.GLYPHPAY_API_URL?.trim() || DEFAULT_BASE_URL;
|
|
576
|
+
const normalized = normalizeApiBaseUrl(raw);
|
|
577
|
+
if (normalized !== DEFAULT_BASE_URL) {
|
|
578
|
+
process.stderr.write(`Glyph Pay: using API origin ${normalized}\n`);
|
|
579
|
+
}
|
|
580
|
+
return normalized;
|
|
475
581
|
}
|
|
476
582
|
function fetchCredential(baseUrl, token) {
|
|
477
583
|
return api(baseUrl, "/credential", undefined, token);
|
|
@@ -510,16 +616,20 @@ export async function runConnect(options = {}, mode = "connect") {
|
|
|
510
616
|
const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
|
|
511
617
|
const callbackUrl = options.url ? toCallbackUrl(options.url) : null;
|
|
512
618
|
const created = await api(baseUrl, "/device", { callbackUrl });
|
|
619
|
+
if (!isSafeBrowserUrl(created.verificationUrl)) {
|
|
620
|
+
throw new CliApiError(0, "invalid_verification_url", "Glyph Pay returned an unsafe browser verification URL.");
|
|
621
|
+
}
|
|
622
|
+
const verificationUrl = new URL(created.verificationUrl).toString();
|
|
513
623
|
say();
|
|
514
624
|
say(style(1, mode === "rotate" ? "Glyph Pay - rotate credential" : "Glyph Pay - connect a site"));
|
|
515
625
|
say();
|
|
516
|
-
say(`1. Open ${style(4,
|
|
517
|
-
say(`2. Sign in and approve pairing code ${style(1, created.userCode)}.`);
|
|
626
|
+
say(`1. Open ${style(4, sanitizeTerminal(verificationUrl))}`);
|
|
627
|
+
say(`2. Sign in and approve pairing code ${style(1, sanitizeTerminal(created.userCode))}.`);
|
|
518
628
|
say(` The code expires at ${new Date(created.expiresAt).toLocaleTimeString()}.`);
|
|
519
629
|
if (options.noOpen) {
|
|
520
630
|
say(" (Browser opening skipped: --no-open.)");
|
|
521
631
|
}
|
|
522
|
-
else if (openBrowser(
|
|
632
|
+
else if (openBrowser(verificationUrl)) {
|
|
523
633
|
say(" A browser window should open.");
|
|
524
634
|
}
|
|
525
635
|
else {
|
|
@@ -528,13 +638,20 @@ export async function runConnect(options = {}, mode = "connect") {
|
|
|
528
638
|
say();
|
|
529
639
|
say(mode === "rotate" ? "Waiting for approval (the previous token stays valid until then)..." : "Waiting for approval...");
|
|
530
640
|
const approved = await waitForApproval(baseUrl, created.deviceCode, created.interval, timeoutSeconds * 1000);
|
|
641
|
+
const tokenMode = TOKEN_PATTERN.exec(approved.token)?.[1];
|
|
642
|
+
if (!tokenMode || tokenMode !== approved.environment) {
|
|
643
|
+
throw new CliApiError(0, "invalid_credential", "Glyph Pay returned a credential that does not match its environment.");
|
|
644
|
+
}
|
|
645
|
+
if (approved.callbackSecret && !CALLBACK_SECRET_PATTERN.test(approved.callbackSecret)) {
|
|
646
|
+
throw new CliApiError(0, "invalid_callback_secret", "Glyph Pay returned a callback secret in an unexpected format.");
|
|
647
|
+
}
|
|
531
648
|
const values = { [TOKEN_KEY]: approved.token };
|
|
532
649
|
if (approved.callbackSecret)
|
|
533
650
|
values[CALLBACK_SECRET_KEY] = approved.callbackSecret;
|
|
534
651
|
say();
|
|
535
|
-
say(style(1, `${mode === "rotate" ? "Rotated" : "Connected"} ${approved.businessName} (${approved.environment} mode).`));
|
|
652
|
+
say(style(1, `${mode === "rotate" ? "Rotated" : "Connected"} ${sanitizeTerminal(approved.businessName)} (${approved.environment} mode).`));
|
|
536
653
|
if (approved.callbackUrl)
|
|
537
|
-
say(`Webhook registered: ${approved.callbackUrl}`);
|
|
654
|
+
say(`Webhook registered: ${sanitizeTerminal(approved.callbackUrl)}`);
|
|
538
655
|
else
|
|
539
656
|
say("No webhook was registered. Add one later in Glyph Pay admin if your site needs payment events.");
|
|
540
657
|
let writtenTo = null;
|
|
@@ -555,7 +672,7 @@ export async function runConnect(options = {}, mode = "connect") {
|
|
|
555
672
|
}
|
|
556
673
|
catch (error) {
|
|
557
674
|
say();
|
|
558
|
-
say(style(33, `Could not write ${file}: ${error instanceof Error ? error.message : String(error)}`));
|
|
675
|
+
say(style(33, `Could not write ${file}: ${sanitizeTerminal(error instanceof Error ? error.message : String(error))}`));
|
|
559
676
|
say("Copy these values into your server environment now; they are shown only this once:");
|
|
560
677
|
for (const [key, value] of Object.entries(values))
|
|
561
678
|
say(`${key}=${value}`);
|
|
@@ -587,7 +704,7 @@ export async function runRotate(options = {}) {
|
|
|
587
704
|
try {
|
|
588
705
|
const info = await fetchCredential(baseUrl, token);
|
|
589
706
|
say();
|
|
590
|
-
say(`Rotating ${info.businessName} (${info.environment} mode).`);
|
|
707
|
+
say(`Rotating ${sanitizeTerminal(info.businessName)} (${sanitizeTerminal(info.environment)} mode).`);
|
|
591
708
|
if (!url && info.callbackUrl)
|
|
592
709
|
url = info.callbackUrl;
|
|
593
710
|
}
|
|
@@ -655,22 +772,21 @@ export async function runDisconnect(options = {}) {
|
|
|
655
772
|
say();
|
|
656
773
|
say(style(1, alreadyRevoked
|
|
657
774
|
? "That credential was already disconnected."
|
|
658
|
-
: `Disconnected ${businessName
|
|
775
|
+
: `Disconnected ${businessName ? sanitizeTerminal(businessName) : "the site"}.`));
|
|
659
776
|
if (removedKeys)
|
|
660
777
|
say(`Removed ${TOKEN_KEY} and ${CALLBACK_SECRET_KEY} from ${displayEnv}.`);
|
|
661
778
|
else
|
|
662
779
|
say(`The credential is revoked. Local values in ${displayEnv} were kept.`);
|
|
663
780
|
return { businessName, removedKeys, alreadyRevoked, canceled: false };
|
|
664
781
|
}
|
|
665
|
-
const TOKEN_PATTERN = /^dgsvc_(test|live)_[A-Za-z0-9_-]{1,128}_[A-Za-z0-9_-]{43}$/;
|
|
666
782
|
function addDoctorCheck(report, check, json) {
|
|
667
783
|
report.checks.push(check);
|
|
668
784
|
if (json)
|
|
669
785
|
return;
|
|
670
786
|
const symbol = check.status === "ok" ? style(32, "✓") : check.status === "warn" ? style(33, "!") : style(31, "✗");
|
|
671
|
-
say(`${symbol} ${check.label}`);
|
|
787
|
+
say(`${symbol} ${sanitizeTerminal(check.label)}`);
|
|
672
788
|
if (check.detail)
|
|
673
|
-
say(` ${check.detail}`);
|
|
789
|
+
say(` ${sanitizeTerminal(check.detail)}`);
|
|
674
790
|
}
|
|
675
791
|
function signedTestEvent(secret, environment) {
|
|
676
792
|
const createdAt = new Date().toISOString();
|
|
@@ -739,6 +855,20 @@ export async function runDoctor(options = {}) {
|
|
|
739
855
|
addDoctorCheck(report, exists
|
|
740
856
|
? { id: "env", status: "ok", label: `Found ${displayEnv}` }
|
|
741
857
|
: { id: "env", status: "error", label: `${displayEnv} was not found`, detail: "Run `glyphpay connect` first or pass --env <path>." }, json);
|
|
858
|
+
if (exists) {
|
|
859
|
+
const ignored = spawnSync("git", ["check-ignore", "-q", "--", envFile], {
|
|
860
|
+
cwd: dirname(envFile),
|
|
861
|
+
stdio: "ignore",
|
|
862
|
+
});
|
|
863
|
+
if (ignored.status === 1) {
|
|
864
|
+
addDoctorCheck(report, {
|
|
865
|
+
id: "env-gitignored",
|
|
866
|
+
status: "warn",
|
|
867
|
+
label: `${displayEnv} is not ignored by git`,
|
|
868
|
+
detail: "Add it to .gitignore before committing; it contains a service credential.",
|
|
869
|
+
}, json);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
742
872
|
const contents = await readEnvFile(envFile);
|
|
743
873
|
const token = readEnvValue(contents, TOKEN_KEY);
|
|
744
874
|
const callbackSecret = readEnvValue(contents, CALLBACK_SECRET_KEY);
|
|
@@ -798,7 +928,15 @@ export async function runDoctor(options = {}) {
|
|
|
798
928
|
? "Complete Stripe onboarding in Design Glyph admin, then re-check."
|
|
799
929
|
: "No connected account is linked to this client yet.",
|
|
800
930
|
}, json);
|
|
801
|
-
if (info.callbackUrl) {
|
|
931
|
+
if (info.callbackUrl && !isProbeableCallbackUrl(info.callbackUrl)) {
|
|
932
|
+
addDoctorCheck(report, {
|
|
933
|
+
id: "callback-url",
|
|
934
|
+
status: "error",
|
|
935
|
+
label: "Webhook URL is not a public HTTPS URL",
|
|
936
|
+
detail: "Refusing to contact it from this machine. Register a public HTTPS callback URL.",
|
|
937
|
+
}, json);
|
|
938
|
+
}
|
|
939
|
+
else if (info.callbackUrl) {
|
|
802
940
|
addDoctorCheck(report, { id: "callback-url", status: "ok", label: "Webhook URL is registered", detail: info.callbackUrl }, json);
|
|
803
941
|
addDoctorCheck(report, callbackSecret
|
|
804
942
|
? { id: "callback-secret", status: "ok", label: "Callback signing secret is present locally" }
|
|
@@ -900,10 +1038,10 @@ export async function runDoctor(options = {}) {
|
|
|
900
1038
|
/* Webhook deliveries */
|
|
901
1039
|
/* ------------------------------------------------------------------ */
|
|
902
1040
|
function printDelivery(delivery) {
|
|
903
|
-
say(` ${delivery.id} ${delivery.type} ${delivery.state.toUpperCase()} ` +
|
|
904
|
-
`attempt ${delivery.attempts} ${delivery.updatedAt}`);
|
|
1041
|
+
say(sanitizeTerminal(` ${delivery.id} ${delivery.type} ${delivery.state.toUpperCase()} ` +
|
|
1042
|
+
`attempt ${delivery.attempts} ${delivery.updatedAt}`));
|
|
905
1043
|
if (delivery.lastError)
|
|
906
|
-
say(` ${delivery.lastError}`);
|
|
1044
|
+
say(` ${sanitizeTerminal(delivery.lastError)}`);
|
|
907
1045
|
}
|
|
908
1046
|
export async function runDeliveries(options = {}) {
|
|
909
1047
|
const baseUrl = baseUrlFor(options);
|
|
@@ -923,7 +1061,7 @@ export async function runDeliveries(options = {}) {
|
|
|
923
1061
|
say(style(1, `Webhook ${result.outcome === "delivered" ? "delivered" : "not delivered"}.`));
|
|
924
1062
|
printDelivery(result.delivery);
|
|
925
1063
|
if (result.detail)
|
|
926
|
-
say(` ${result.detail}`);
|
|
1064
|
+
say(` ${sanitizeTerminal(result.detail)}`);
|
|
927
1065
|
}
|
|
928
1066
|
return result;
|
|
929
1067
|
}
|
|
@@ -941,7 +1079,7 @@ export async function runDeliveries(options = {}) {
|
|
|
941
1079
|
return result;
|
|
942
1080
|
}
|
|
943
1081
|
say();
|
|
944
|
-
say(style(1, `Webhook deliveries - ${result.businessName} (${result.environment} mode)`));
|
|
1082
|
+
say(style(1, `Webhook deliveries - ${sanitizeTerminal(result.businessName)} (${sanitizeTerminal(result.environment)} mode)`));
|
|
945
1083
|
say();
|
|
946
1084
|
if (result.deliveries.length === 0) {
|
|
947
1085
|
say(options.state ? `No ${options.state} deliveries recorded.` : "No deliveries recorded yet.");
|
|
@@ -1255,7 +1393,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1255
1393
|
return 2;
|
|
1256
1394
|
}
|
|
1257
1395
|
say();
|
|
1258
|
-
say(style(31, error instanceof Error ? error.message : String(error)));
|
|
1396
|
+
say(style(31, sanitizeTerminal(error instanceof Error ? error.message : String(error))));
|
|
1259
1397
|
return 1;
|
|
1260
1398
|
}
|
|
1261
1399
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side URL safety helpers. Kept separate from the Stripe loader so the
|
|
3
|
+
* rules can be tested without a DOM.
|
|
4
|
+
*/
|
|
5
|
+
/** Resolve a post-payment return URL, rejecting cross-origin targets unless explicitly allowed. */
|
|
6
|
+
export declare function resolveReturnUrl(returnUrl: string, baseHref: string, allowCrossOrigin?: boolean): string;
|
package/dist/security.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side URL safety helpers. Kept separate from the Stripe loader so the
|
|
3
|
+
* rules can be tested without a DOM.
|
|
4
|
+
*/
|
|
5
|
+
/** Resolve a post-payment return URL, rejecting cross-origin targets unless explicitly allowed. */
|
|
6
|
+
export function resolveReturnUrl(returnUrl, baseHref, allowCrossOrigin = false) {
|
|
7
|
+
const base = new URL(baseHref);
|
|
8
|
+
const target = new URL(returnUrl, base);
|
|
9
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
10
|
+
throw new Error("returnUrl must be an http(s) URL.");
|
|
11
|
+
}
|
|
12
|
+
if (!allowCrossOrigin && target.origin !== base.origin) {
|
|
13
|
+
throw new Error("returnUrl must use this site's origin.");
|
|
14
|
+
}
|
|
15
|
+
return target.href;
|
|
16
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -8,8 +8,15 @@ export interface GlyphPayOptions {
|
|
|
8
8
|
token: string | undefined;
|
|
9
9
|
/** Signing secret shown when the callback URL was saved in Design Glyph admin. Needed for `webhook`. */
|
|
10
10
|
callbackSecret?: string;
|
|
11
|
-
/** Defaults to the Design Glyph production origin. */
|
|
11
|
+
/** Defaults to the Design Glyph production origin. Non-HTTPS is allowed only for loopback development. */
|
|
12
12
|
baseUrl?: string;
|
|
13
|
+
/** Additional browser origins allowed to call the checkout route. Defaults to same-origin only. */
|
|
14
|
+
allowedCheckoutOrigins?: string[];
|
|
15
|
+
/**
|
|
16
|
+
* Optional hook for rate limiting, a bot challenge, or a cart/session check
|
|
17
|
+
* before a PaymentIntent is created. Throw `CheckoutError` to show its message.
|
|
18
|
+
*/
|
|
19
|
+
beforeCheckout?: (req: Request) => Promise<void> | void;
|
|
13
20
|
/**
|
|
14
21
|
* Price the order from YOUR data (DB, cart, CMS). Never trust an amount sent by the browser.
|
|
15
22
|
* `orderId` must change whenever the amount or description changes (e.g. `${cartId}-v${version}`):
|
|
@@ -17,6 +24,12 @@ export interface GlyphPayOptions {
|
|
|
17
24
|
* Throw `CheckoutError` to show the shopper a message.
|
|
18
25
|
*/
|
|
19
26
|
priceOrder?: (body: unknown, req: Request) => Promise<Order> | Order;
|
|
27
|
+
/**
|
|
28
|
+
* Optional replay guard. Return true when this event id was already
|
|
29
|
+
* processed. GlyphPay delivers at-least-once, so handlers must still be
|
|
30
|
+
* idempotent if this hook is not used.
|
|
31
|
+
*/
|
|
32
|
+
seen?: (eventId: string) => Promise<boolean> | boolean;
|
|
20
33
|
/** Runs once per verified callback. Delivery is at-least-once, so dedupe on `event.id`. Throw to have it retried. */
|
|
21
34
|
onEvent?: (event: GlyphPayEvent) => Promise<void> | void;
|
|
22
35
|
}
|
|
@@ -72,9 +85,11 @@ export declare class GlyphPayError extends Error {
|
|
|
72
85
|
status: number;
|
|
73
86
|
constructor(status: number, message: string);
|
|
74
87
|
}
|
|
75
|
-
/** Throw from `priceOrder` to send `message` to the shopper with a 400. */
|
|
88
|
+
/** Throw from `priceOrder` or `beforeCheckout` to send `message` to the shopper with a 400. */
|
|
76
89
|
export declare class CheckoutError extends Error {
|
|
77
90
|
}
|
|
91
|
+
/** Validate and normalize the API origin. HTTPS is required outside loopback development. */
|
|
92
|
+
export declare function normalizeBaseUrl(raw: string): string;
|
|
78
93
|
export declare function glyphPay(options: GlyphPayOptions): {
|
|
79
94
|
/** Route handler: `export const POST = glyphpay.checkout`. Prices the order, returns what the browser needs. */
|
|
80
95
|
checkout: (req: Request) => Promise<Response>;
|
|
@@ -91,5 +106,9 @@ export declare function glyphPay(options: GlyphPayOptions): {
|
|
|
91
106
|
/** Re-read the payment from Stripe if a callback looks missed. */
|
|
92
107
|
reconcile: (paymentId: string) => Promise<Payment>;
|
|
93
108
|
};
|
|
94
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* Verifies `Designglyph-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "t.body")>`.
|
|
111
|
+
* Multiple `v1` values are accepted so a secret can be rotated with an overlap.
|
|
112
|
+
* Rejects >5 min skew and returns the parsed event.
|
|
113
|
+
*/
|
|
95
114
|
export declare function verifyCallback(rawBody: string, header: string | null, secret: string): Promise<GlyphPayEvent>;
|
package/dist/server.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* must only ever run on the client site's server (route handlers, API routes).
|
|
4
4
|
* Runtime-agnostic: uses fetch + Web Crypto (Node 20+, Next.js, edge, Workers).
|
|
5
5
|
*/
|
|
6
|
+
const DEFAULT_BASE_URL = "https://designglyph.web.app";
|
|
7
|
+
const MAX_BODY_BYTES = 1_000_000;
|
|
6
8
|
/** Error from the GlyphPay API (status/message are the API's). */
|
|
7
9
|
export class GlyphPayError extends Error {
|
|
8
10
|
status;
|
|
@@ -11,13 +13,115 @@ export class GlyphPayError extends Error {
|
|
|
11
13
|
this.status = status;
|
|
12
14
|
}
|
|
13
15
|
}
|
|
14
|
-
/** Throw from `priceOrder` to send `message` to the shopper with a 400. */
|
|
16
|
+
/** Throw from `priceOrder` or `beforeCheckout` to send `message` to the shopper with a 400. */
|
|
15
17
|
export class CheckoutError extends Error {
|
|
16
18
|
}
|
|
19
|
+
/** Validate and normalize the API origin. HTTPS is required outside loopback development. */
|
|
20
|
+
export function normalizeBaseUrl(raw) {
|
|
21
|
+
let url;
|
|
22
|
+
try {
|
|
23
|
+
url = new URL(raw);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new GlyphPayError(500, "GlyphPay baseUrl is not a valid URL.");
|
|
27
|
+
}
|
|
28
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
29
|
+
const loopback = host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
30
|
+
if (url.protocol !== "https:" && !(loopback && url.protocol === "http:")) {
|
|
31
|
+
throw new GlyphPayError(500, "GlyphPay baseUrl must use https (http is allowed only for localhost).");
|
|
32
|
+
}
|
|
33
|
+
if (url.username || url.password) {
|
|
34
|
+
throw new GlyphPayError(500, "GlyphPay baseUrl must not contain credentials.");
|
|
35
|
+
}
|
|
36
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
37
|
+
const normalized = `${url.origin}${path}`;
|
|
38
|
+
if (normalized !== DEFAULT_BASE_URL)
|
|
39
|
+
console.warn(`GlyphPay: using non-default API origin ${normalized}.`);
|
|
40
|
+
return normalized;
|
|
41
|
+
}
|
|
42
|
+
async function readBody(req, maxBytes = MAX_BODY_BYTES) {
|
|
43
|
+
const lengthHeader = req.headers.get("content-length");
|
|
44
|
+
if (lengthHeader !== null) {
|
|
45
|
+
const length = Number(lengthHeader);
|
|
46
|
+
if (!Number.isSafeInteger(length) || length < 0)
|
|
47
|
+
throw new GlyphPayError(400, "Invalid Content-Length.");
|
|
48
|
+
if (length > maxBytes)
|
|
49
|
+
throw new GlyphPayError(413, "Request body is too large.");
|
|
50
|
+
}
|
|
51
|
+
if (!req.body)
|
|
52
|
+
return "";
|
|
53
|
+
const reader = req.body.getReader();
|
|
54
|
+
const chunks = [];
|
|
55
|
+
let total = 0;
|
|
56
|
+
try {
|
|
57
|
+
for (;;) {
|
|
58
|
+
const { done, value } = await reader.read();
|
|
59
|
+
if (done)
|
|
60
|
+
break;
|
|
61
|
+
total += value.byteLength;
|
|
62
|
+
if (total > maxBytes) {
|
|
63
|
+
await reader.cancel().catch(() => undefined);
|
|
64
|
+
throw new GlyphPayError(413, "Request body is too large.");
|
|
65
|
+
}
|
|
66
|
+
chunks.push(value);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
try {
|
|
71
|
+
reader.releaseLock();
|
|
72
|
+
}
|
|
73
|
+
catch { /* already released */ }
|
|
74
|
+
}
|
|
75
|
+
const merged = new Uint8Array(total);
|
|
76
|
+
let offset = 0;
|
|
77
|
+
for (const chunk of chunks) {
|
|
78
|
+
merged.set(chunk, offset);
|
|
79
|
+
offset += chunk.byteLength;
|
|
80
|
+
}
|
|
81
|
+
return new TextDecoder().decode(merged);
|
|
82
|
+
}
|
|
83
|
+
async function readJson(req) {
|
|
84
|
+
const text = await readBody(req);
|
|
85
|
+
if (!text)
|
|
86
|
+
return null;
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(text);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function checkoutOriginAllowed(req, allowed) {
|
|
95
|
+
const origin = req.headers.get("origin");
|
|
96
|
+
if (!origin)
|
|
97
|
+
return true; // server-to-server callers do not send Origin
|
|
98
|
+
try {
|
|
99
|
+
return origin === new URL(req.url).origin || allowed.includes(origin);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Parse `t=…,v1=…,v1=…` without object keys, so header values cannot pollute prototypes. */
|
|
106
|
+
function parseSignatureHeader(header) {
|
|
107
|
+
const result = { timestamp: null, signatures: [] };
|
|
108
|
+
for (const raw of (header ?? "").split(",")) {
|
|
109
|
+
const index = raw.indexOf("=");
|
|
110
|
+
if (index < 1)
|
|
111
|
+
continue;
|
|
112
|
+
const key = raw.slice(0, index).trim();
|
|
113
|
+
const value = raw.slice(index + 1).trim();
|
|
114
|
+
if (key === "t" && result.timestamp === null)
|
|
115
|
+
result.timestamp = value;
|
|
116
|
+
if (key === "v1")
|
|
117
|
+
result.signatures.push(value);
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
17
121
|
export function glyphPay(options) {
|
|
18
|
-
const baseUrl = (options.baseUrl ??
|
|
122
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
|
|
19
123
|
async function call(method, path, body) {
|
|
20
|
-
//
|
|
124
|
+
// Checked per call, not at construction, so `next build` works without env vars.
|
|
21
125
|
if (!/^dgsvc_(test|live)_/.test(options.token ?? ""))
|
|
22
126
|
throw new GlyphPayError(500, "GlyphPay token is missing or not a dgsvc_ service credential.");
|
|
23
127
|
const res = await fetch(`${baseUrl}/api/client/v1${path}`, {
|
|
@@ -47,13 +151,25 @@ export function glyphPay(options) {
|
|
|
47
151
|
try {
|
|
48
152
|
if (!options.priceOrder)
|
|
49
153
|
throw new Error("GlyphPay: priceOrder is required for checkout.");
|
|
50
|
-
|
|
154
|
+
if (!checkoutOriginAllowed(req, options.allowedCheckoutOrigins ?? [])) {
|
|
155
|
+
return Response.json({ error: "Checkout requests must come from this site." }, { status: 403 });
|
|
156
|
+
}
|
|
157
|
+
const contentType = req.headers.get("content-type")?.split(";")[0].trim().toLowerCase();
|
|
158
|
+
if (contentType && contentType !== "application/json") {
|
|
159
|
+
return Response.json({ error: "Checkout requires application/json." }, { status: 415 });
|
|
160
|
+
}
|
|
161
|
+
const body = await readJson(req);
|
|
162
|
+
await options.beforeCheckout?.(req);
|
|
163
|
+
const session = await api.createPayment(await options.priceOrder(body, req));
|
|
51
164
|
const { clientSecret, publishableKey, stripeAccount } = session;
|
|
52
165
|
return Response.json({ clientSecret, publishableKey, stripeAccount, paymentId: session.id }, { headers: { "cache-control": "no-store" } });
|
|
53
166
|
}
|
|
54
167
|
catch (error) {
|
|
55
168
|
if (error instanceof CheckoutError)
|
|
56
169
|
return Response.json({ error: error.message }, { status: 400 });
|
|
170
|
+
if (error instanceof GlyphPayError && error.status === 413) {
|
|
171
|
+
return Response.json({ error: "Checkout request is too large." }, { status: 413 });
|
|
172
|
+
}
|
|
57
173
|
console.error("GlyphPay checkout failed:", error instanceof Error ? error.message : error);
|
|
58
174
|
return Response.json({ error: "Checkout is unavailable right now. Please try again." }, { status: 502 });
|
|
59
175
|
}
|
|
@@ -63,14 +179,30 @@ export function glyphPay(options) {
|
|
|
63
179
|
// 5xx makes GlyphPay retry (up to 72h), so config and handler errors don't lose events. A bad signature is a permanent 400.
|
|
64
180
|
if (!options.callbackSecret)
|
|
65
181
|
return new Response("GlyphPay callback secret not configured", { status: 500 });
|
|
182
|
+
let rawBody;
|
|
183
|
+
try {
|
|
184
|
+
rawBody = await readBody(req);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
const tooLarge = error instanceof GlyphPayError && error.status === 413;
|
|
188
|
+
return new Response(tooLarge ? "Payload too large" : "Bad request", { status: tooLarge ? 413 : 400 });
|
|
189
|
+
}
|
|
66
190
|
let event;
|
|
67
191
|
try {
|
|
68
|
-
event = await verifyCallback(
|
|
192
|
+
event = await verifyCallback(rawBody, req.headers.get("designglyph-signature"), options.callbackSecret);
|
|
69
193
|
}
|
|
70
194
|
catch {
|
|
71
195
|
return new Response("Invalid signature", { status: 400 });
|
|
72
196
|
}
|
|
197
|
+
const tokenMode = /^dgsvc_(test|live)_/.exec(options.token ?? "")?.[1];
|
|
198
|
+
if (!tokenMode)
|
|
199
|
+
return new Response("GlyphPay token not configured", { status: 500 });
|
|
200
|
+
if (event.livemode !== (tokenMode === "live")) {
|
|
201
|
+
return new Response("Event mode does not match this credential", { status: 400 });
|
|
202
|
+
}
|
|
73
203
|
try {
|
|
204
|
+
if (options.seen && (await options.seen(event.id)))
|
|
205
|
+
return new Response(null, { status: 204 });
|
|
74
206
|
await options.onEvent?.(event);
|
|
75
207
|
}
|
|
76
208
|
catch (error) {
|
|
@@ -81,16 +213,34 @@ export function glyphPay(options) {
|
|
|
81
213
|
},
|
|
82
214
|
};
|
|
83
215
|
}
|
|
84
|
-
/**
|
|
216
|
+
/**
|
|
217
|
+
* Verifies `Designglyph-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "t.body")>`.
|
|
218
|
+
* Multiple `v1` values are accepted so a secret can be rotated with an overlap.
|
|
219
|
+
* Rejects >5 min skew and returns the parsed event.
|
|
220
|
+
*/
|
|
85
221
|
export async function verifyCallback(rawBody, header, secret) {
|
|
86
|
-
|
|
222
|
+
if (!secret)
|
|
223
|
+
throw new GlyphPayError(400, "Invalid GlyphPay signature.");
|
|
224
|
+
const { timestamp, signatures } = parseSignatureHeader(header);
|
|
225
|
+
const timestampNumber = Number(timestamp);
|
|
226
|
+
const fresh = typeof timestamp === "string" && /^\d{1,12}$/.test(timestamp) &&
|
|
227
|
+
Math.abs(Date.now() / 1000 - timestampNumber) <= 300;
|
|
228
|
+
const validHex = signatures.filter((signature) => /^[0-9a-f]{64}$/.test(signature));
|
|
229
|
+
if (!fresh || validHex.length === 0)
|
|
230
|
+
throw new GlyphPayError(400, "Invalid GlyphPay signature.");
|
|
87
231
|
const enc = new TextEncoder();
|
|
88
232
|
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
233
|
+
const signed = enc.encode(`${timestamp}.${rawBody}`);
|
|
234
|
+
let matched = false;
|
|
235
|
+
for (const signature of validHex) {
|
|
236
|
+
const bytes = Uint8Array.from(signature.match(/../g), (hex) => parseInt(hex, 16));
|
|
237
|
+
// subtle.verify compares in constant time.
|
|
238
|
+
if (await crypto.subtle.verify("HMAC", key, bytes, signed)) {
|
|
239
|
+
matched = true;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
94
242
|
}
|
|
243
|
+
if (!matched)
|
|
244
|
+
throw new GlyphPayError(400, "Invalid GlyphPay signature.");
|
|
95
245
|
return JSON.parse(rawBody);
|
|
96
246
|
}
|
package/package.json
CHANGED