glyphpay 0.5.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 CHANGED
@@ -19,6 +19,12 @@ The CLI covers setup, diagnostics, and credential lifecycle:
19
19
  | `glyphpay deliveries` | Page/filter webhook deliveries and retry one with `--retry <id>`. |
20
20
  | `glyphpay init` | Scaffold routes for Next.js, Remix, Astro, SvelteKit, Hono, or Express. |
21
21
 
22
+ ## 1.0 compatibility
23
+
24
+ `glyphpay` 1.x follows Semantic Versioning. The documented package exports and the `/api/client/v1` and `/api/cli/v1` HTTP contracts accept additive changes only; ignore unknown response fields and event types. Breaking changes ship under a new API version with an overlap period. The full rules are in `docs/versioning-policy.md`.
25
+
26
+ The CLI supports **Node 18+**. The server connector (`glyphpay/server`) uses `fetch` and Web Crypto and targets **Node 20+**, Next.js, edge runtimes, and Cloudflare Workers. A 1.0 release is a code and contract milestone: live payments remain gated by sandbox acceptance, deployment verification, and written approval.
27
+
22
28
  ## Connect a site from the CLI
23
29
 
24
30
  ```sh
@@ -37,6 +43,8 @@ GLYPHPAY_CALLBACK_SECRET=dgcb_...
37
43
  - `--no-open`, `--base-url` and `--timeout` help scripts and local testing.
38
44
  - Codes expire after ten minutes. Approving again rotates the token: the previous token stops working immediately.
39
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.
40
48
 
41
49
  The browser never receives the credential. Keep the token in server environment variables and never prefix it with `NEXT_PUBLIC_`, `VITE_` or `PUBLIC_`.
42
50
 
@@ -49,7 +57,7 @@ npx glyphpay rotate
49
57
  npx glyphpay disconnect
50
58
  ```
51
59
 
52
- `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:
53
61
 
54
62
  ```sh
55
63
  npx glyphpay doctor --send-test-event
@@ -66,7 +74,7 @@ npx glyphpay deliveries
66
74
  npx glyphpay deliveries --retry evt_...
67
75
  ```
68
76
 
69
- `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 exact stored signed event again and persists the result. Retry is one attempt; fix the endpoint first. The admin **Connections** tab exposes the same filtering, pagination, and retry operations.
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.
70
78
 
71
79
  ## Scaffold a site
72
80
 
@@ -89,7 +97,7 @@ It refuses to overwrite existing files unless you pass `--force`; use `--dry-run
89
97
 
90
98
  1. **Admin → Clients → (client)**: make sure Stripe onboarding is complete (charges enabled).
91
99
  2. **Issue a service credential** (test first). Copy the `dgsvc_test_…` token. It is shown only once.
92
- 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.
93
101
 
94
102
  ## Add to the client site
95
103
 
@@ -114,16 +122,23 @@ export const glyphpay = glyphPay({
114
122
  callbackSecret: process.env.GLYPHPAY_CALLBACK_SECRET,
115
123
 
116
124
  // Price the order from YOUR data. Never trust an amount sent by the browser.
117
- async priceOrder(body) {
118
- const cart = await db.carts.get((body as { cartId: string }).cartId);
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;
119
129
  if (!cart?.items.length) throw new CheckoutError("Your cart is empty.");
120
130
  // orderId must change whenever the total changes, or GlyphPay returns 409.
121
131
  return { orderId: `${cart.id}-v${cart.version}`, amountCents: cart.totalCents, description: `Order ${cart.id}` };
122
132
  },
123
133
 
134
+ // Optional fast path; a unique insert in onEvent also works.
135
+ seen: (eventId) => db.processedEvents.has(eventId),
136
+
124
137
  // Signed and verified. At-least-once delivery: dedupe on event.id. Throw to have it retried.
125
138
  async onEvent(event) {
139
+ if (await db.processedEvents.has(event.id)) return;
126
140
  if (event.type === "payment.succeeded") await db.orders.markPaid(event.data.orderId, event.id);
141
+ await db.processedEvents.add(event.id);
127
142
  },
128
143
  });
129
144
  ```
@@ -142,6 +157,8 @@ import { glyphpay } from "@/lib/glyphpay";
142
157
  export const POST = glyphpay.webhook;
143
158
  ```
144
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
+
145
162
  Checkout page:
146
163
 
147
164
  ```tsx
@@ -150,6 +167,8 @@ import { GlyphPayCheckout } from "glyphpay/react";
150
167
  <GlyphPayCheckout order={{ cartId }} returnUrl={`/thank-you?cart=${cartId}`} appearance={{ theme: "stripe" }} />
151
168
  ```
152
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
+
153
172
  Style the button with `.glyphpay button { … }`. Errors appear in `.glyphpay [role=alert]`.
154
173
 
155
174
  Without React:
@@ -158,7 +177,8 @@ Without React:
158
177
  <div id="pay"></div>
159
178
  <script type="module">
160
179
  import { mountCheckout } from "glyphpay/browser";
161
- mountCheckout(document.getElementById("pay"), { order: { cartId: "42" }, returnUrl: "/thank-you" });
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" });
162
182
  </script>
163
183
  ```
164
184
 
@@ -169,7 +189,7 @@ The route handlers take a standard `Request` and return a `Response`, so the sam
169
189
  1. The component POSTs `order` to `/api/glyphpay/checkout`. `priceOrder` sets the amount, and the site's server calls GlyphPay with the token.
170
190
  2. The browser gets back only `clientSecret`, `publishableKey`, and `stripeAccount`, then mounts Stripe's Payment Element.
171
191
  3. The shopper pays, and Stripe redirects to `returnUrl`. **The redirect doesn't prove payment.**
172
- 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.
173
193
 
174
194
  Refunds run server-side:
175
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. Confirm the order server-side there (callback/getPayment), not from the URL. */
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
- // On success Stripe always redirects to returnUrl (adds payment_intent & redirect_status params).
46
- const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: new URL(options.returnUrl, location.href).href } });
47
- message.textContent = error.message ?? "Payment failed. Please try again.";
48
- button.disabled = false;
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(url: string): boolean;
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 = "0.5.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
- if (url.protocol !== "https:" && url.protocol !== "http:") {
309
- throw new CliUsageError("The site URL must start with https:// (or http:// for local development).");
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(url) {
436
- const command = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
437
- const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
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
- return options.baseUrl?.trim() || process.env.GLYPHPAY_API_URL?.trim() || DEFAULT_BASE_URL;
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, created.verificationUrl)}`);
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(created.verificationUrl)) {
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 ?? "the site"}.`));
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;
@@ -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
- /** Verifies `Designglyph-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "t.body")>` and returns the event. Rejects >5 min skew. */
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 ?? "https://designglyph.web.app").replace(/\/$/, "");
122
+ const baseUrl = normalizeBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
19
123
  async function call(method, path, body) {
20
- // ponytail: checked per call, not at construction, so `next build` works without env vars.
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
- const session = await api.createPayment(await options.priceOrder(await req.json().catch(() => null), req));
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(await req.text(), req.headers.get("designglyph-signature"), options.callbackSecret);
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
- /** Verifies `Designglyph-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "t.body")>` and returns the event. Rejects >5 min skew. */
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
- const { t, v1 } = Object.fromEntries((header ?? "").split(",").map((part) => part.trim().split("=")));
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 signature = /^[0-9a-f]{64}$/.test(v1 ?? "") ? Uint8Array.from(v1.match(/../g), (h) => parseInt(h, 16)) : null;
90
- const fresh = Math.abs(Date.now() / 1000 - Number(t)) <= 300;
91
- // subtle.verify compares in constant time.
92
- if (!signature || !fresh || !(await crypto.subtle.verify("HMAC", key, signature, enc.encode(`${t}.${rawBody}`)))) {
93
- throw new GlyphPayError(400, "Invalid GlyphPay signature.");
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
@@ -1,60 +1,60 @@
1
- {
2
- "name": "glyphpay",
3
- "version": "0.5.0",
4
- "description": "Drop-in GlyphPay checkout for websites, with a CLI for setup, diagnostics, and credential lifecycle.",
5
- "homepage": "https://glyphpay.net",
6
- "keywords": [
7
- "glyphpay",
8
- "payments",
9
- "checkout",
10
- "stripe",
11
- "react",
12
- "nextjs"
13
- ],
14
- "license": "UNLICENSED",
15
- "type": "module",
16
- "bin": {
17
- "glyphpay": "dist/cli.js"
18
- },
19
- "files": [
20
- "dist"
21
- ],
22
- "engines": {
23
- "node": ">=18"
24
- },
25
- "exports": {
26
- "./server": {
27
- "types": "./dist/server.d.ts",
28
- "default": "./dist/server.js"
29
- },
30
- "./browser": {
31
- "types": "./dist/browser.d.ts",
32
- "default": "./dist/browser.js"
33
- },
34
- "./react": {
35
- "types": "./dist/react.d.ts",
36
- "default": "./dist/react.js"
37
- }
38
- },
39
- "scripts": {
40
- "build": "tsc",
41
- "prepare": "tsc",
42
- "test": "tsc && node --test test/*.test.mjs"
43
- },
44
- "dependencies": {
45
- "@stripe/stripe-js": "^10.2.1"
46
- },
47
- "peerDependencies": {
48
- "react": ">=18"
49
- },
50
- "peerDependenciesMeta": {
51
- "react": {
52
- "optional": true
53
- }
54
- },
55
- "devDependencies": {
56
- "@types/node": "^22.20.5",
57
- "@types/react": "^19",
58
- "typescript": "^5"
59
- }
60
- }
1
+ {
2
+ "name": "glyphpay",
3
+ "version": "1.0.1",
4
+ "description": "Drop-in GlyphPay checkout for websites, with a CLI for setup, diagnostics, and credential lifecycle.",
5
+ "homepage": "https://glyphpay.net",
6
+ "keywords": [
7
+ "glyphpay",
8
+ "payments",
9
+ "checkout",
10
+ "stripe",
11
+ "react",
12
+ "nextjs"
13
+ ],
14
+ "license": "UNLICENSED",
15
+ "type": "module",
16
+ "bin": {
17
+ "glyphpay": "dist/cli.js"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "exports": {
26
+ "./server": {
27
+ "types": "./dist/server.d.ts",
28
+ "default": "./dist/server.js"
29
+ },
30
+ "./browser": {
31
+ "types": "./dist/browser.d.ts",
32
+ "default": "./dist/browser.js"
33
+ },
34
+ "./react": {
35
+ "types": "./dist/react.d.ts",
36
+ "default": "./dist/react.js"
37
+ }
38
+ },
39
+ "scripts": {
40
+ "build": "tsc",
41
+ "prepare": "tsc",
42
+ "test": "tsc && node --test test/*.test.mjs"
43
+ },
44
+ "dependencies": {
45
+ "@stripe/stripe-js": "^10.2.1"
46
+ },
47
+ "peerDependencies": {
48
+ "react": ">=18"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "react": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^22.20.5",
57
+ "@types/react": "^19",
58
+ "typescript": "^5"
59
+ }
60
+ }