glyphpay 0.0.0-stage → 0.2.0
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 +127 -2
- package/dist/browser.d.ts +21 -0
- package/dist/browser.js +52 -0
- package/dist/cli.d.ts +58 -0
- package/dist/cli.js +409 -0
- package/dist/react.d.ts +6 -0
- package/dist/react.js +13 -0
- package/dist/server.d.ts +95 -0
- package/dist/server.js +96 -0
- package/package.json +58 -4
package/README.md
CHANGED
|
@@ -1,3 +1,128 @@
|
|
|
1
|
-
#
|
|
1
|
+
# glyphpay
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Drop-in connector that lets a website take payments through [GlyphPay](https://glyphpay.net) by Design Glyph. Payments go to the site owner's own Stripe connected account.
|
|
4
|
+
|
|
5
|
+
| Import | Runs on | What it gives you |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| `glyphpay/server` | Site's server | `glyphPay()`: `checkout` and `webhook` route handlers, plus `createPayment`, `getPayment`, `refund`, `reconcile` |
|
|
8
|
+
| `glyphpay/react` | Browser | `<GlyphPayCheckout />` |
|
|
9
|
+
| `glyphpay/browser` | Browser, any site | `mountCheckout(el, options)` for sites without React |
|
|
10
|
+
|
|
11
|
+
## Connect a site from the CLI
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npx glyphpay connect --url https://client-site.com
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The command opens Glyph Pay in your browser. Sign in as the client's billing email (or a Design Glyph admin), pick the client account, confirm the webhook URL and approve the pairing code. The terminal receives a test service credential and, when a webhook URL was registered, its signing secret. It writes both to `.env.local`:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
GLYPHPAY_TOKEN=dgsvc_test_...
|
|
21
|
+
GLYPHPAY_CALLBACK_SECRET=dgcb_...
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- `--url <site>` registers `<site>/api/glyphpay/webhook`. Pass a full URL to use a different route.
|
|
25
|
+
- `--env <path>` targets another env file; `--print` prints the values instead of writing.
|
|
26
|
+
- `--no-open`, `--base-url` and `--timeout` help scripts and local testing.
|
|
27
|
+
- Codes expire after ten minutes. Approving again rotates the token: the previous token stops working immediately.
|
|
28
|
+
- Pairing is disabled while the platform is in live mode.
|
|
29
|
+
|
|
30
|
+
The browser never receives the credential. Keep the token in server environment variables and never prefix it with `NEXT_PUBLIC_`, `VITE_` or `PUBLIC_`.
|
|
31
|
+
|
|
32
|
+
## Manual setup: connect the client in Design Glyph admin
|
|
33
|
+
|
|
34
|
+
1. **Admin → Clients → (client)**: make sure Stripe onboarding is complete (charges enabled).
|
|
35
|
+
2. **Issue a service credential** (test first). Copy the `dgsvc_test_…` token. It is shown only once.
|
|
36
|
+
3. **Set the callback URL** to `https://<client-site>/api/glyphpay/webhook`. Copy the signing secret. It is also shown only once.
|
|
37
|
+
|
|
38
|
+
## 2. Add to the client site
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
pnpm add glyphpay
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`.env` (server only, never `NEXT_PUBLIC_`):
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
GLYPHPAY_TOKEN=dgsvc_test_...
|
|
48
|
+
GLYPHPAY_CALLBACK_SECRET=...
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`lib/glyphpay.ts`
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { glyphPay, CheckoutError } from "glyphpay/server";
|
|
55
|
+
|
|
56
|
+
export const glyphpay = glyphPay({
|
|
57
|
+
token: process.env.GLYPHPAY_TOKEN,
|
|
58
|
+
callbackSecret: process.env.GLYPHPAY_CALLBACK_SECRET,
|
|
59
|
+
|
|
60
|
+
// Price the order from YOUR data. Never trust an amount sent by the browser.
|
|
61
|
+
async priceOrder(body) {
|
|
62
|
+
const cart = await db.carts.get((body as { cartId: string }).cartId);
|
|
63
|
+
if (!cart?.items.length) throw new CheckoutError("Your cart is empty.");
|
|
64
|
+
// orderId must change whenever the total changes, or GlyphPay returns 409.
|
|
65
|
+
return { orderId: `${cart.id}-v${cart.version}`, amountCents: cart.totalCents, description: `Order ${cart.id}` };
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
// Signed and verified. At-least-once delivery: dedupe on event.id. Throw to have it retried.
|
|
69
|
+
async onEvent(event) {
|
|
70
|
+
if (event.type === "payment.succeeded") await db.orders.markPaid(event.data.orderId, event.id);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`app/api/glyphpay/checkout/route.ts`
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { glyphpay } from "@/lib/glyphpay";
|
|
79
|
+
export const POST = glyphpay.checkout;
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`app/api/glyphpay/webhook/route.ts`
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { glyphpay } from "@/lib/glyphpay";
|
|
86
|
+
export const POST = glyphpay.webhook;
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Checkout page:
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
import { GlyphPayCheckout } from "glyphpay/react";
|
|
93
|
+
|
|
94
|
+
<GlyphPayCheckout order={{ cartId }} returnUrl={`/thank-you?cart=${cartId}`} appearance={{ theme: "stripe" }} />
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Style the button with `.glyphpay button { … }`. Errors appear in `.glyphpay [role=alert]`.
|
|
98
|
+
|
|
99
|
+
Without React:
|
|
100
|
+
|
|
101
|
+
```html
|
|
102
|
+
<div id="pay"></div>
|
|
103
|
+
<script type="module">
|
|
104
|
+
import { mountCheckout } from "glyphpay/browser";
|
|
105
|
+
mountCheckout(document.getElementById("pay"), { order: { cartId: "42" }, returnUrl: "/thank-you" });
|
|
106
|
+
</script>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The route handlers take a standard `Request` and return a `Response`, so the same two lines work in Remix, Astro, Hono, SvelteKit, and Cloudflare Workers. In Express, use `createPayment` and `verifyCallback` directly.
|
|
110
|
+
|
|
111
|
+
## How a payment flows
|
|
112
|
+
|
|
113
|
+
1. The component POSTs `order` to `/api/glyphpay/checkout`. `priceOrder` sets the amount, and the site's server calls GlyphPay with the token.
|
|
114
|
+
2. The browser gets back only `clientSecret`, `publishableKey`, and `stripeAccount`, then mounts Stripe's Payment Element.
|
|
115
|
+
3. The shopper pays, and Stripe redirects to `returnUrl`. **The redirect doesn't prove payment.**
|
|
116
|
+
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)`.
|
|
117
|
+
|
|
118
|
+
Refunds run server-side:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
await glyphpay.refund(paymentId, { idempotencyKey: `${orderId}-refund-1` });
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Develop
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
cd packages/glyphpay && npm install && npm test
|
|
128
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GlyphPay connector, browser half. Framework-free: works on any site.
|
|
3
|
+
* Talks only to the site's own checkout route; the service credential never reaches the browser.
|
|
4
|
+
*/
|
|
5
|
+
import { type Appearance } from "@stripe/stripe-js";
|
|
6
|
+
export interface CheckoutOptions {
|
|
7
|
+
/** JSON posted to your checkout route, e.g. `{ cartId }`. Your server prices it; never send a trusted amount. */
|
|
8
|
+
order: unknown;
|
|
9
|
+
/** Where Stripe sends the shopper after paying. Confirm the order server-side there (callback/getPayment), not from the URL. */
|
|
10
|
+
returnUrl: string;
|
|
11
|
+
/** Your site's checkout route. Default `/api/glyphpay/checkout`. */
|
|
12
|
+
endpoint?: string;
|
|
13
|
+
/** Stripe Elements appearance (theme, colors, fonts). */
|
|
14
|
+
appearance?: Appearance;
|
|
15
|
+
buttonText?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Renders `<form class="glyphpay">` (Stripe Payment Element + submit button + `role=alert` message)
|
|
19
|
+
* into `container`. Style it with `.glyphpay button { … }`. Returns an unmount function.
|
|
20
|
+
*/
|
|
21
|
+
export declare function mountCheckout(container: HTMLElement, options: CheckoutOptions): () => void;
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GlyphPay connector, browser half. Framework-free: works on any site.
|
|
3
|
+
* Talks only to the site's own checkout route; the service credential never reaches the browser.
|
|
4
|
+
*/
|
|
5
|
+
import { loadStripe } from "@stripe/stripe-js";
|
|
6
|
+
/**
|
|
7
|
+
* Renders `<form class="glyphpay">` (Stripe Payment Element + submit button + `role=alert` message)
|
|
8
|
+
* into `container`. Style it with `.glyphpay button { … }`. Returns an unmount function.
|
|
9
|
+
*/
|
|
10
|
+
export function mountCheckout(container, options) {
|
|
11
|
+
let unmounted = false;
|
|
12
|
+
const form = document.createElement("form");
|
|
13
|
+
form.className = "glyphpay";
|
|
14
|
+
const slot = document.createElement("div");
|
|
15
|
+
const button = document.createElement("button");
|
|
16
|
+
button.type = "submit";
|
|
17
|
+
button.disabled = true;
|
|
18
|
+
button.textContent = options.buttonText ?? "Pay now";
|
|
19
|
+
const message = document.createElement("p");
|
|
20
|
+
message.setAttribute("role", "alert");
|
|
21
|
+
form.append(slot, button, message);
|
|
22
|
+
container.append(form);
|
|
23
|
+
(async () => {
|
|
24
|
+
const res = await fetch(options.endpoint ?? "/api/glyphpay/checkout", {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "content-type": "application/json" },
|
|
27
|
+
body: JSON.stringify(options.order ?? null),
|
|
28
|
+
});
|
|
29
|
+
const session = await res.json().catch(() => ({}));
|
|
30
|
+
if (!res.ok)
|
|
31
|
+
throw new Error(session.error ?? "Checkout is unavailable right now.");
|
|
32
|
+
const stripe = await loadStripe(session.publishableKey, { stripeAccount: session.stripeAccount });
|
|
33
|
+
if (!stripe)
|
|
34
|
+
throw new Error("Could not load the payment form.");
|
|
35
|
+
if (unmounted)
|
|
36
|
+
return;
|
|
37
|
+
const elements = stripe.elements({ clientSecret: session.clientSecret, appearance: options.appearance });
|
|
38
|
+
const payment = elements.create("payment");
|
|
39
|
+
payment.on("ready", () => { button.disabled = false; });
|
|
40
|
+
payment.mount(slot);
|
|
41
|
+
form.addEventListener("submit", async (event) => {
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
button.disabled = true;
|
|
44
|
+
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;
|
|
49
|
+
});
|
|
50
|
+
})().catch((error) => { message.textContent = error.message; });
|
|
51
|
+
return () => { unmounted = true; form.remove(); };
|
|
52
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `glyphpay connect` - browser pairing for a client site.
|
|
4
|
+
*
|
|
5
|
+
* The CLI never asks for a password or an API token. It creates a short-lived
|
|
6
|
+
* pairing request, opens the Glyph Pay site, and waits while the signed-in
|
|
7
|
+
* owner approves it in the browser. The service credential and webhook signing
|
|
8
|
+
* secret are returned only to this process, then written to the site's server
|
|
9
|
+
* environment file.
|
|
10
|
+
*
|
|
11
|
+
* Zero runtime dependencies: Node 18+ built-ins and global fetch only.
|
|
12
|
+
*/
|
|
13
|
+
export declare class CliUsageError extends Error {
|
|
14
|
+
constructor(message: string);
|
|
15
|
+
}
|
|
16
|
+
export declare class CliApiError extends Error {
|
|
17
|
+
readonly status: number;
|
|
18
|
+
readonly code: string;
|
|
19
|
+
constructor(status: number, code: string, message: string);
|
|
20
|
+
}
|
|
21
|
+
export interface ConnectOptions {
|
|
22
|
+
/** Site URL (a webhook path is appended when the URL has no path), or the exact callback URL. */
|
|
23
|
+
url?: string;
|
|
24
|
+
/** Env file to update. Defaults to `.env.local`. */
|
|
25
|
+
envFile?: string;
|
|
26
|
+
/** Print the credentials instead of writing them to a file. */
|
|
27
|
+
print?: boolean;
|
|
28
|
+
/** Skip opening a browser. */
|
|
29
|
+
noOpen?: boolean;
|
|
30
|
+
/** API origin. Defaults to GLYPHPAY_API_URL or production. */
|
|
31
|
+
baseUrl?: string;
|
|
32
|
+
/** How long to wait for browser approval. */
|
|
33
|
+
timeoutSeconds?: number;
|
|
34
|
+
}
|
|
35
|
+
export interface ConnectResult {
|
|
36
|
+
businessName: string;
|
|
37
|
+
environment: "test" | "live";
|
|
38
|
+
callbackUrl: string | null;
|
|
39
|
+
writtenTo: string | null;
|
|
40
|
+
}
|
|
41
|
+
interface ParsedCommand {
|
|
42
|
+
command: "connect" | "help" | "version";
|
|
43
|
+
options: ConnectOptions;
|
|
44
|
+
}
|
|
45
|
+
export declare function parseArgs(argv: string[]): ParsedCommand;
|
|
46
|
+
export declare function helpText(): string;
|
|
47
|
+
/** `shop.example.com` -> `https://shop.example.com/api/glyphpay/webhook`. */
|
|
48
|
+
export declare function toCallbackUrl(raw: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* Replace values in an env file without touching unrelated lines. The first
|
|
51
|
+
* occurrence of a managed key is rewritten; duplicate managed keys are dropped
|
|
52
|
+
* so the file keeps a single source of truth.
|
|
53
|
+
*/
|
|
54
|
+
export declare function upsertEnv(contents: string, values: Record<string, string>): string;
|
|
55
|
+
export declare function openBrowser(url: string): boolean;
|
|
56
|
+
export declare function runConnect(options?: ConnectOptions): Promise<ConnectResult>;
|
|
57
|
+
export declare function main(argv?: string[]): Promise<number>;
|
|
58
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `glyphpay connect` - browser pairing for a client site.
|
|
4
|
+
*
|
|
5
|
+
* The CLI never asks for a password or an API token. It creates a short-lived
|
|
6
|
+
* pairing request, opens the Glyph Pay site, and waits while the signed-in
|
|
7
|
+
* owner approves it in the browser. The service credential and webhook signing
|
|
8
|
+
* secret are returned only to this process, then written to the site's server
|
|
9
|
+
* environment file.
|
|
10
|
+
*
|
|
11
|
+
* Zero runtime dependencies: Node 18+ built-ins and global fetch only.
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
15
|
+
import { dirname, relative, resolve } from "node:path";
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
17
|
+
const VERSION = "0.2.0";
|
|
18
|
+
const DEFAULT_BASE_URL = "https://designglyph.web.app";
|
|
19
|
+
const DEFAULT_ENV_FILE = ".env.local";
|
|
20
|
+
const DEFAULT_TIMEOUT_SECONDS = 600;
|
|
21
|
+
const TOKEN_KEY = "GLYPHPAY_TOKEN";
|
|
22
|
+
const CALLBACK_SECRET_KEY = "GLYPHPAY_CALLBACK_SECRET";
|
|
23
|
+
const WEBHOOK_PATH = "/api/glyphpay/webhook";
|
|
24
|
+
export class CliUsageError extends Error {
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "CliUsageError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export class CliApiError extends Error {
|
|
31
|
+
status;
|
|
32
|
+
code;
|
|
33
|
+
constructor(status, code, message) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "CliApiError";
|
|
36
|
+
this.status = status;
|
|
37
|
+
this.code = code;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const useColor = !!process.stdout.isTTY && !process.env.NO_COLOR;
|
|
41
|
+
function style(code, text) {
|
|
42
|
+
return useColor ? `\u001b[${code}m${text}\u001b[0m` : text;
|
|
43
|
+
}
|
|
44
|
+
function say(text = "") {
|
|
45
|
+
process.stdout.write(`${text}\n`);
|
|
46
|
+
}
|
|
47
|
+
function sleep(ms) {
|
|
48
|
+
return new Promise((done) => setTimeout(done, ms));
|
|
49
|
+
}
|
|
50
|
+
/* ------------------------------------------------------------------ */
|
|
51
|
+
/* Argument parsing */
|
|
52
|
+
/* ------------------------------------------------------------------ */
|
|
53
|
+
function takeValue(argv, index, flag) {
|
|
54
|
+
const value = argv[index + 1];
|
|
55
|
+
if (!value || value.startsWith("-"))
|
|
56
|
+
throw new CliUsageError(`${flag} needs a value.`);
|
|
57
|
+
return [value, index + 1];
|
|
58
|
+
}
|
|
59
|
+
export function parseArgs(argv) {
|
|
60
|
+
const options = {};
|
|
61
|
+
let command = "connect";
|
|
62
|
+
let sawCommand = false;
|
|
63
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
64
|
+
const argument = argv[index];
|
|
65
|
+
if (argument === "--help" || argument === "-h" || argument === "help")
|
|
66
|
+
return { command: "help", options };
|
|
67
|
+
if (argument === "--version" || argument === "-v")
|
|
68
|
+
return { command: "version", options };
|
|
69
|
+
if (argument === "connect" || argument === "login") {
|
|
70
|
+
if (sawCommand)
|
|
71
|
+
throw new CliUsageError("Only one command can run at a time.");
|
|
72
|
+
sawCommand = true;
|
|
73
|
+
command = "connect";
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (argument === "--print") {
|
|
77
|
+
options.print = true;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (argument === "--no-open") {
|
|
81
|
+
options.noOpen = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const [flag, inlineValue] = argument.includes("=") ? argument.split(/=(.*)/s, 2) : [argument, undefined];
|
|
85
|
+
if (flag === "--url" || flag === "--callback-url") {
|
|
86
|
+
if (inlineValue !== undefined) {
|
|
87
|
+
if (!inlineValue)
|
|
88
|
+
throw new CliUsageError(`${flag} needs a value.`);
|
|
89
|
+
options.url = inlineValue;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const [value, next] = takeValue(argv, index, flag);
|
|
93
|
+
options.url = value;
|
|
94
|
+
index = next;
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (flag === "--env") {
|
|
99
|
+
if (inlineValue !== undefined) {
|
|
100
|
+
if (!inlineValue)
|
|
101
|
+
throw new CliUsageError("--env needs a path.");
|
|
102
|
+
options.envFile = inlineValue;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
const [value, next] = takeValue(argv, index, flag);
|
|
106
|
+
options.envFile = value;
|
|
107
|
+
index = next;
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (flag === "--base-url") {
|
|
112
|
+
if (inlineValue !== undefined) {
|
|
113
|
+
if (!inlineValue)
|
|
114
|
+
throw new CliUsageError("--base-url needs a URL.");
|
|
115
|
+
options.baseUrl = inlineValue;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
const [value, next] = takeValue(argv, index, flag);
|
|
119
|
+
options.baseUrl = value;
|
|
120
|
+
index = next;
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (flag === "--timeout") {
|
|
125
|
+
const raw = inlineValue ?? takeValue(argv, index, flag)[0];
|
|
126
|
+
if (inlineValue === undefined)
|
|
127
|
+
index += 1;
|
|
128
|
+
const parsed = Number(raw);
|
|
129
|
+
if (!Number.isFinite(parsed) || parsed < 10 || parsed > 1800) {
|
|
130
|
+
throw new CliUsageError("--timeout must be between 10 and 1800 seconds.");
|
|
131
|
+
}
|
|
132
|
+
options.timeoutSeconds = Math.floor(parsed);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (argument.startsWith("-"))
|
|
136
|
+
throw new CliUsageError(`Unknown option: ${argument}`);
|
|
137
|
+
throw new CliUsageError(`Unknown command: ${argument}`);
|
|
138
|
+
}
|
|
139
|
+
return { command, options };
|
|
140
|
+
}
|
|
141
|
+
export function helpText() {
|
|
142
|
+
return `glyphpay - take payments on your site through Glyph Pay
|
|
143
|
+
|
|
144
|
+
Usage
|
|
145
|
+
glyphpay connect [options] Connect a site (alias: glyphpay login)
|
|
146
|
+
glyphpay --version
|
|
147
|
+
|
|
148
|
+
Options
|
|
149
|
+
--url <site-url> Site to connect. Adds /api/glyphpay/webhook when the
|
|
150
|
+
URL has no path. Or pass the exact callback URL.
|
|
151
|
+
--env <path> Env file to update (default: ${DEFAULT_ENV_FILE})
|
|
152
|
+
--print Print the credentials instead of writing a file
|
|
153
|
+
--no-open Do not open a browser automatically
|
|
154
|
+
--base-url <url> Glyph Pay API origin (default: ${DEFAULT_BASE_URL})
|
|
155
|
+
--timeout <seconds> Wait for approval (default: ${DEFAULT_TIMEOUT_SECONDS})
|
|
156
|
+
-h, --help Show this help
|
|
157
|
+
-v, --version Show the version
|
|
158
|
+
|
|
159
|
+
The browser step signs in to Glyph Pay, lets you pick the client account, and
|
|
160
|
+
approves this machine. The service credential goes only to this terminal.`;
|
|
161
|
+
}
|
|
162
|
+
/* ------------------------------------------------------------------ */
|
|
163
|
+
/* URL and env-file helpers */
|
|
164
|
+
/* ------------------------------------------------------------------ */
|
|
165
|
+
/** `shop.example.com` -> `https://shop.example.com/api/glyphpay/webhook`. */
|
|
166
|
+
export function toCallbackUrl(raw) {
|
|
167
|
+
let value = raw.trim();
|
|
168
|
+
if (!value)
|
|
169
|
+
throw new CliUsageError("Enter the site's URL.");
|
|
170
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(value))
|
|
171
|
+
value = `https://${value}`;
|
|
172
|
+
let url;
|
|
173
|
+
try {
|
|
174
|
+
url = new URL(value);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
throw new CliUsageError(`"${raw}" is not a valid URL.`);
|
|
178
|
+
}
|
|
179
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
180
|
+
throw new CliUsageError("The site URL must start with https:// (or http:// for local development).");
|
|
181
|
+
}
|
|
182
|
+
if (url.username || url.password)
|
|
183
|
+
throw new CliUsageError("The site URL must not contain a username or password.");
|
|
184
|
+
if (url.pathname === "/" || url.pathname === "")
|
|
185
|
+
url.pathname = WEBHOOK_PATH;
|
|
186
|
+
url.hash = "";
|
|
187
|
+
return url.toString();
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Replace values in an env file without touching unrelated lines. The first
|
|
191
|
+
* occurrence of a managed key is rewritten; duplicate managed keys are dropped
|
|
192
|
+
* so the file keeps a single source of truth.
|
|
193
|
+
*/
|
|
194
|
+
export function upsertEnv(contents, values) {
|
|
195
|
+
const eol = contents.includes("\r\n") ? "\r\n" : "\n";
|
|
196
|
+
const lines = contents.length
|
|
197
|
+
? contents.replace(/\r\n/g, "\n").replace(/\n+$/, "").split("\n")
|
|
198
|
+
: [];
|
|
199
|
+
const pending = new Map(Object.entries(values));
|
|
200
|
+
const handled = new Set();
|
|
201
|
+
const output = [];
|
|
202
|
+
for (const line of lines) {
|
|
203
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
|
204
|
+
const key = match?.[1];
|
|
205
|
+
if (key && pending.has(key)) {
|
|
206
|
+
output.push(`${key}=${pending.get(key)}`);
|
|
207
|
+
pending.delete(key);
|
|
208
|
+
handled.add(key);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (key && handled.has(key))
|
|
212
|
+
continue;
|
|
213
|
+
output.push(line);
|
|
214
|
+
}
|
|
215
|
+
for (const [key, value] of pending)
|
|
216
|
+
output.push(`${key}=${value}`);
|
|
217
|
+
return `${output.join(eol)}${eol}`;
|
|
218
|
+
}
|
|
219
|
+
function isNotFound(error) {
|
|
220
|
+
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
221
|
+
}
|
|
222
|
+
async function readEnvFile(path) {
|
|
223
|
+
try {
|
|
224
|
+
return await readFile(path, "utf8");
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (isNotFound(error))
|
|
228
|
+
return "";
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async function writeEnvFile(path, contents) {
|
|
233
|
+
await mkdir(dirname(path), { recursive: true });
|
|
234
|
+
await writeFile(path, contents, { encoding: "utf8", mode: 0o600 });
|
|
235
|
+
try {
|
|
236
|
+
await chmod(path, 0o600);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Windows does not use POSIX modes; the file still lives in the project.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/* ------------------------------------------------------------------ */
|
|
243
|
+
/* Browser and API */
|
|
244
|
+
/* ------------------------------------------------------------------ */
|
|
245
|
+
export function openBrowser(url) {
|
|
246
|
+
const command = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
247
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
248
|
+
try {
|
|
249
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
250
|
+
child.on("error", () => undefined);
|
|
251
|
+
child.unref();
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async function api(baseUrl, path, body) {
|
|
259
|
+
let response;
|
|
260
|
+
try {
|
|
261
|
+
response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/cli/v1${path}`, {
|
|
262
|
+
method: body === undefined ? "GET" : "POST",
|
|
263
|
+
headers: {
|
|
264
|
+
accept: "application/json",
|
|
265
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
266
|
+
"user-agent": `glyphpay-cli/${VERSION}`,
|
|
267
|
+
},
|
|
268
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
269
|
+
signal: AbortSignal.timeout(20_000),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
throw new CliApiError(0, "network", "Could not reach Glyph Pay. Check your connection and try again.");
|
|
274
|
+
}
|
|
275
|
+
const payload = (await response.json().catch(() => null));
|
|
276
|
+
if (!response.ok || !payload?.ok) {
|
|
277
|
+
throw new CliApiError(response.status, payload && "error" in payload ? payload.error?.code ?? "request_failed" : "request_failed", (payload && "error" in payload ? payload.error?.message : null) ??
|
|
278
|
+
`Glyph Pay request failed (HTTP ${response.status}).`);
|
|
279
|
+
}
|
|
280
|
+
return payload.data;
|
|
281
|
+
}
|
|
282
|
+
async function waitForApproval(baseUrl, deviceCode, firstInterval, timeoutMs) {
|
|
283
|
+
const deadline = Date.now() + timeoutMs;
|
|
284
|
+
let interval = Math.max(1, firstInterval);
|
|
285
|
+
let lastNetworkError = null;
|
|
286
|
+
while (Date.now() < deadline) {
|
|
287
|
+
await sleep(interval * 1000);
|
|
288
|
+
try {
|
|
289
|
+
const result = await api(baseUrl, "/device/token", { deviceCode });
|
|
290
|
+
if (result.status === "approved")
|
|
291
|
+
return result;
|
|
292
|
+
interval = Math.max(1, result.interval || interval);
|
|
293
|
+
lastNetworkError = null;
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
if (error instanceof CliApiError && error.status === 429) {
|
|
297
|
+
interval = Math.min(interval + 2, 10);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (error instanceof CliApiError && (error.status === 404 || error.status === 410))
|
|
301
|
+
throw error;
|
|
302
|
+
lastNetworkError = error instanceof Error ? error.message : String(error);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
throw new CliApiError(0, "timeout", `Timed out waiting for approval${lastNetworkError ? ` (last error: ${lastNetworkError})` : ""}. ` +
|
|
306
|
+
"Run the command again for a fresh code.");
|
|
307
|
+
}
|
|
308
|
+
/* ------------------------------------------------------------------ */
|
|
309
|
+
/* Connect */
|
|
310
|
+
/* ------------------------------------------------------------------ */
|
|
311
|
+
export async function runConnect(options = {}) {
|
|
312
|
+
const baseUrl = options.baseUrl?.trim() || process.env.GLYPHPAY_API_URL?.trim() || DEFAULT_BASE_URL;
|
|
313
|
+
const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
|
|
314
|
+
const callbackUrl = options.url ? toCallbackUrl(options.url) : null;
|
|
315
|
+
const created = await api(baseUrl, "/device", { callbackUrl });
|
|
316
|
+
say();
|
|
317
|
+
say(style(1, "Glyph Pay - connect a site"));
|
|
318
|
+
say();
|
|
319
|
+
say(`1. Open ${style(4, created.verificationUrl)}`);
|
|
320
|
+
say(`2. Sign in and approve pairing code ${style(1, created.userCode)}.`);
|
|
321
|
+
say(` The code expires at ${new Date(created.expiresAt).toLocaleTimeString()}.`);
|
|
322
|
+
if (options.noOpen) {
|
|
323
|
+
say(" (Browser opening skipped: --no-open.)");
|
|
324
|
+
}
|
|
325
|
+
else if (openBrowser(created.verificationUrl)) {
|
|
326
|
+
say(" A browser window should open.");
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
say(" Could not open a browser. Open the link above manually.");
|
|
330
|
+
}
|
|
331
|
+
say();
|
|
332
|
+
say("Waiting for approval...");
|
|
333
|
+
const approved = await waitForApproval(baseUrl, created.deviceCode, created.interval, timeoutSeconds * 1000);
|
|
334
|
+
const values = { [TOKEN_KEY]: approved.token };
|
|
335
|
+
if (approved.callbackSecret)
|
|
336
|
+
values[CALLBACK_SECRET_KEY] = approved.callbackSecret;
|
|
337
|
+
say();
|
|
338
|
+
say(style(1, `Connected ${approved.businessName} (${approved.environment} mode).`));
|
|
339
|
+
if (approved.callbackUrl)
|
|
340
|
+
say(`Webhook registered: ${approved.callbackUrl}`);
|
|
341
|
+
else
|
|
342
|
+
say("No webhook was registered. Add one later in Glyph Pay admin if your site needs payment events.");
|
|
343
|
+
let writtenTo = null;
|
|
344
|
+
if (options.print) {
|
|
345
|
+
say();
|
|
346
|
+
say("Add these to your server environment (never NEXT_PUBLIC_):");
|
|
347
|
+
for (const [key, value] of Object.entries(values))
|
|
348
|
+
say(`${key}=${value}`);
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
const file = resolve(options.envFile ?? DEFAULT_ENV_FILE);
|
|
352
|
+
try {
|
|
353
|
+
const next = upsertEnv(await readEnvFile(file), values);
|
|
354
|
+
await writeEnvFile(file, next);
|
|
355
|
+
writtenTo = file;
|
|
356
|
+
const display = relative(process.cwd(), file) || file;
|
|
357
|
+
say(`Wrote ${Object.keys(values).join(" and ")} to ${display}.`);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
say();
|
|
361
|
+
say(style(33, `Could not write ${file}: ${error instanceof Error ? error.message : String(error)}`));
|
|
362
|
+
say("Copy these values into your server environment now; they are shown only this once:");
|
|
363
|
+
for (const [key, value] of Object.entries(values))
|
|
364
|
+
say(`${key}=${value}`);
|
|
365
|
+
throw new CliApiError(1, "env_write_failed", "The credentials were printed above but not saved to a file.");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
say();
|
|
369
|
+
say("The token is shown only once. Run this command again to rotate it.");
|
|
370
|
+
return {
|
|
371
|
+
businessName: approved.businessName,
|
|
372
|
+
environment: approved.environment,
|
|
373
|
+
callbackUrl: approved.callbackUrl,
|
|
374
|
+
writtenTo,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
/* ------------------------------------------------------------------ */
|
|
378
|
+
/* Entry point */
|
|
379
|
+
/* ------------------------------------------------------------------ */
|
|
380
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
381
|
+
try {
|
|
382
|
+
const parsed = parseArgs(argv);
|
|
383
|
+
if (parsed.command === "help") {
|
|
384
|
+
say(helpText());
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
387
|
+
if (parsed.command === "version") {
|
|
388
|
+
say(VERSION);
|
|
389
|
+
return 0;
|
|
390
|
+
}
|
|
391
|
+
await runConnect(parsed.options);
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
if (error instanceof CliUsageError) {
|
|
396
|
+
say(style(31, error.message));
|
|
397
|
+
say("Run `glyphpay --help` for usage.");
|
|
398
|
+
return 2;
|
|
399
|
+
}
|
|
400
|
+
say();
|
|
401
|
+
say(style(31, error instanceof Error ? error.message : String(error)));
|
|
402
|
+
return 1;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
406
|
+
void main().then((code) => {
|
|
407
|
+
process.exitCode = code;
|
|
408
|
+
});
|
|
409
|
+
}
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type CheckoutOptions } from "./browser.js";
|
|
2
|
+
export type { CheckoutOptions };
|
|
3
|
+
/** Drop-in checkout. Remounts when `order` changes; other props are read once on mount. */
|
|
4
|
+
export declare function GlyphPayCheckout({ className, ...options }: CheckoutOptions & {
|
|
5
|
+
className?: string;
|
|
6
|
+
}): import("react").JSX.Element;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { mountCheckout } from "./browser.js";
|
|
5
|
+
/** Drop-in checkout. Remounts when `order` changes; other props are read once on mount. */
|
|
6
|
+
export function GlyphPayCheckout({ className, ...options }) {
|
|
7
|
+
const ref = useRef(null);
|
|
8
|
+
const orderKey = JSON.stringify(options.order);
|
|
9
|
+
// ponytail: keyed on order only, so inline appearance/returnUrl objects don't remount Stripe every render.
|
|
10
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
11
|
+
useEffect(() => mountCheckout(ref.current, options), [orderKey]);
|
|
12
|
+
return _jsx("div", { ref: ref, className: className });
|
|
13
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GlyphPay connector, server half. Holds the dgsvc_ service credential, so it
|
|
3
|
+
* must only ever run on the client site's server (route handlers, API routes).
|
|
4
|
+
* Runtime-agnostic: uses fetch + Web Crypto (Node 20+, Next.js, edge, Workers).
|
|
5
|
+
*/
|
|
6
|
+
export interface GlyphPayOptions {
|
|
7
|
+
/** Service credential from Design Glyph admin (dgsvc_test_… / dgsvc_live_…). Never ship to the browser. */
|
|
8
|
+
token: string | undefined;
|
|
9
|
+
/** Signing secret shown when the callback URL was saved in Design Glyph admin. Needed for `webhook`. */
|
|
10
|
+
callbackSecret?: string;
|
|
11
|
+
/** Defaults to the Design Glyph production origin. */
|
|
12
|
+
baseUrl?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Price the order from YOUR data (DB, cart, CMS). Never trust an amount sent by the browser.
|
|
15
|
+
* `orderId` must change whenever the amount or description changes (e.g. `${cartId}-v${version}`):
|
|
16
|
+
* GlyphPay ties one payment to one orderId and rejects a changed amount with a 409.
|
|
17
|
+
* Throw `CheckoutError` to show the shopper a message.
|
|
18
|
+
*/
|
|
19
|
+
priceOrder?: (body: unknown, req: Request) => Promise<Order> | Order;
|
|
20
|
+
/** Runs once per verified callback. Delivery is at-least-once, so dedupe on `event.id`. Throw to have it retried. */
|
|
21
|
+
onEvent?: (event: GlyphPayEvent) => Promise<void> | void;
|
|
22
|
+
}
|
|
23
|
+
export interface Order {
|
|
24
|
+
orderId: string;
|
|
25
|
+
amountCents: number;
|
|
26
|
+
description: string;
|
|
27
|
+
}
|
|
28
|
+
export interface CheckoutSession {
|
|
29
|
+
id: string;
|
|
30
|
+
paymentIntentId: string;
|
|
31
|
+
clientSecret: string;
|
|
32
|
+
publishableKey: string;
|
|
33
|
+
stripeAccount: string;
|
|
34
|
+
}
|
|
35
|
+
export type PaymentStatus = "creating" | "requires_payment_method" | "processing" | "succeeded" | "canceled" | "failed";
|
|
36
|
+
export interface Payment {
|
|
37
|
+
id: string;
|
|
38
|
+
orderId: string;
|
|
39
|
+
status: PaymentStatus;
|
|
40
|
+
amountCents: number;
|
|
41
|
+
applicationFeeCents: number;
|
|
42
|
+
currency: "usd";
|
|
43
|
+
refundedCents: number;
|
|
44
|
+
refunds: {
|
|
45
|
+
stripeRefundId: string | null;
|
|
46
|
+
amountCents: number;
|
|
47
|
+
status: string;
|
|
48
|
+
}[];
|
|
49
|
+
disputes: {
|
|
50
|
+
status: string;
|
|
51
|
+
amountCents: number;
|
|
52
|
+
}[];
|
|
53
|
+
/** True while not terminal. Callbacks (or polling) are the source of truth, never the browser redirect. */
|
|
54
|
+
stale: boolean;
|
|
55
|
+
updatedAt: string;
|
|
56
|
+
}
|
|
57
|
+
export interface Refund {
|
|
58
|
+
paymentId: string;
|
|
59
|
+
refundId: string;
|
|
60
|
+
amountCents: number;
|
|
61
|
+
status: string;
|
|
62
|
+
}
|
|
63
|
+
export interface GlyphPayEvent {
|
|
64
|
+
id: string;
|
|
65
|
+
type: "payment.succeeded" | "payment.failed" | "payment.processing" | "payment.canceled" | "payment.refund_updated" | "payment.dispute_updated";
|
|
66
|
+
createdAt: string;
|
|
67
|
+
livemode: boolean;
|
|
68
|
+
data: Payment;
|
|
69
|
+
}
|
|
70
|
+
/** Error from the GlyphPay API (status/message are the API's). */
|
|
71
|
+
export declare class GlyphPayError extends Error {
|
|
72
|
+
status: number;
|
|
73
|
+
constructor(status: number, message: string);
|
|
74
|
+
}
|
|
75
|
+
/** Throw from `priceOrder` to send `message` to the shopper with a 400. */
|
|
76
|
+
export declare class CheckoutError extends Error {
|
|
77
|
+
}
|
|
78
|
+
export declare function glyphPay(options: GlyphPayOptions): {
|
|
79
|
+
/** Route handler: `export const POST = glyphpay.checkout`. Prices the order, returns what the browser needs. */
|
|
80
|
+
checkout: (req: Request) => Promise<Response>;
|
|
81
|
+
/** Route handler for the callback URL registered in Design Glyph admin: `export const POST = glyphpay.webhook`. */
|
|
82
|
+
webhook: (req: Request) => Promise<Response>;
|
|
83
|
+
/** Idempotent per orderId: calling again returns the same payment. */
|
|
84
|
+
createPayment: (order: Order) => Promise<CheckoutSession>;
|
|
85
|
+
getPayment: (paymentId: string) => Promise<Payment>;
|
|
86
|
+
/** Omit amountCents to refund the remaining balance. Reusing an idempotencyKey replays the same refund. */
|
|
87
|
+
refund: (paymentId: string, input: {
|
|
88
|
+
idempotencyKey: string;
|
|
89
|
+
amountCents?: number;
|
|
90
|
+
}) => Promise<Refund>;
|
|
91
|
+
/** Re-read the payment from Stripe if a callback looks missed. */
|
|
92
|
+
reconcile: (paymentId: string) => Promise<Payment>;
|
|
93
|
+
};
|
|
94
|
+
/** Verifies `Designglyph-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "t.body")>` and returns the event. Rejects >5 min skew. */
|
|
95
|
+
export declare function verifyCallback(rawBody: string, header: string | null, secret: string): Promise<GlyphPayEvent>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GlyphPay connector, server half. Holds the dgsvc_ service credential, so it
|
|
3
|
+
* must only ever run on the client site's server (route handlers, API routes).
|
|
4
|
+
* Runtime-agnostic: uses fetch + Web Crypto (Node 20+, Next.js, edge, Workers).
|
|
5
|
+
*/
|
|
6
|
+
/** Error from the GlyphPay API (status/message are the API's). */
|
|
7
|
+
export class GlyphPayError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
constructor(status, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Throw from `priceOrder` to send `message` to the shopper with a 400. */
|
|
15
|
+
export class CheckoutError extends Error {
|
|
16
|
+
}
|
|
17
|
+
export function glyphPay(options) {
|
|
18
|
+
const baseUrl = (options.baseUrl ?? "https://designglyph.web.app").replace(/\/$/, "");
|
|
19
|
+
async function call(method, path, body) {
|
|
20
|
+
// ponytail: checked per call, not at construction, so `next build` works without env vars.
|
|
21
|
+
if (!/^dgsvc_(test|live)_/.test(options.token ?? ""))
|
|
22
|
+
throw new GlyphPayError(500, "GlyphPay token is missing or not a dgsvc_ service credential.");
|
|
23
|
+
const res = await fetch(`${baseUrl}/api/client/v1${path}`, {
|
|
24
|
+
method,
|
|
25
|
+
headers: { authorization: `Bearer ${options.token}`, ...(body === undefined ? {} : { "content-type": "application/json" }) },
|
|
26
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
27
|
+
});
|
|
28
|
+
const json = (await res.json().catch(() => null));
|
|
29
|
+
if (!res.ok || !json?.ok)
|
|
30
|
+
throw new GlyphPayError(res.status, json?.error?.message ?? `GlyphPay request failed (HTTP ${res.status}).`);
|
|
31
|
+
return json.data;
|
|
32
|
+
}
|
|
33
|
+
const id = (paymentId) => encodeURIComponent(paymentId);
|
|
34
|
+
const api = {
|
|
35
|
+
/** Idempotent per orderId: calling again returns the same payment. */
|
|
36
|
+
createPayment: (order) => call("POST", "/payments", { ...order, currency: "usd" }),
|
|
37
|
+
getPayment: (paymentId) => call("GET", `/payments/${id(paymentId)}`),
|
|
38
|
+
/** Omit amountCents to refund the remaining balance. Reusing an idempotencyKey replays the same refund. */
|
|
39
|
+
refund: (paymentId, input) => call("POST", `/payments/${id(paymentId)}/refunds`, input),
|
|
40
|
+
/** Re-read the payment from Stripe if a callback looks missed. */
|
|
41
|
+
reconcile: (paymentId) => call("POST", `/payments/${id(paymentId)}/reconcile`),
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
...api,
|
|
45
|
+
/** Route handler: `export const POST = glyphpay.checkout`. Prices the order, returns what the browser needs. */
|
|
46
|
+
checkout: async (req) => {
|
|
47
|
+
try {
|
|
48
|
+
if (!options.priceOrder)
|
|
49
|
+
throw new Error("GlyphPay: priceOrder is required for checkout.");
|
|
50
|
+
const session = await api.createPayment(await options.priceOrder(await req.json().catch(() => null), req));
|
|
51
|
+
const { clientSecret, publishableKey, stripeAccount } = session;
|
|
52
|
+
return Response.json({ clientSecret, publishableKey, stripeAccount, paymentId: session.id }, { headers: { "cache-control": "no-store" } });
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error instanceof CheckoutError)
|
|
56
|
+
return Response.json({ error: error.message }, { status: 400 });
|
|
57
|
+
console.error("GlyphPay checkout failed:", error instanceof Error ? error.message : error);
|
|
58
|
+
return Response.json({ error: "Checkout is unavailable right now. Please try again." }, { status: 502 });
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
/** Route handler for the callback URL registered in Design Glyph admin: `export const POST = glyphpay.webhook`. */
|
|
62
|
+
webhook: async (req) => {
|
|
63
|
+
// 5xx makes GlyphPay retry (up to 72h), so config and handler errors don't lose events. A bad signature is a permanent 400.
|
|
64
|
+
if (!options.callbackSecret)
|
|
65
|
+
return new Response("GlyphPay callback secret not configured", { status: 500 });
|
|
66
|
+
let event;
|
|
67
|
+
try {
|
|
68
|
+
event = await verifyCallback(await req.text(), req.headers.get("designglyph-signature"), options.callbackSecret);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return new Response("Invalid signature", { status: 400 });
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
await options.onEvent?.(event);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
console.error(`GlyphPay event ${event.id} handler failed:`, error instanceof Error ? error.message : error);
|
|
78
|
+
return new Response("Handler failed", { status: 500 });
|
|
79
|
+
}
|
|
80
|
+
return new Response(null, { status: 204 });
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Verifies `Designglyph-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "t.body")>` and returns the event. Rejects >5 min skew. */
|
|
85
|
+
export async function verifyCallback(rawBody, header, secret) {
|
|
86
|
+
const { t, v1 } = Object.fromEntries((header ?? "").split(",").map((part) => part.trim().split("=")));
|
|
87
|
+
const enc = new TextEncoder();
|
|
88
|
+
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.");
|
|
94
|
+
}
|
|
95
|
+
return JSON.parse(rawBody);
|
|
96
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glyphpay",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Drop-in GlyphPay checkout for websites, plus `glyphpay connect` for browser-based setup.",
|
|
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
|
+
}
|