perly-pay 0.1.0 → 0.1.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 +1 -1
- package/dist/cli/detect.js +6 -1
- package/dist/cli/env.js +24 -0
- package/dist/cli/init.js +68 -8
- package/dist/cli/main.js +1 -1
- package/dist/cli/templates.js +55 -20
- package/dist/http.d.ts +1 -1
- package/dist/http.js +1 -1
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -198,7 +198,7 @@ Add your site's origin under Dashboard → Settings → Checkout so the checkout
|
|
|
198
198
|
|
|
199
199
|
- Docs: <https://pay.perly.io/docs> · API reference: <https://pay.perly.io/docs/api> · SDKs: <https://pay.perly.io/docs/sdks>
|
|
200
200
|
- OpenAPI: <https://pay.perly.io/api/v1/openapi.json>
|
|
201
|
-
- PHP: [`perly/perly-pay`](https://pay.perly.io/docs/sdks)
|
|
201
|
+
- PHP: [`perly/perly-pay`](https://pay.perly.io/docs/sdks), the same calls and the same webhook check, is not on Packagist yet; until then PHP calls the API directly, and `npx perly-pay init` in a Laravel project writes files that do (Laravel's HTTP client and the signature check written out)
|
|
202
202
|
|
|
203
203
|
## Build
|
|
204
204
|
|
package/dist/cli/detect.js
CHANGED
|
@@ -152,10 +152,15 @@ export function detectProject(dir, forced) {
|
|
|
152
152
|
notes,
|
|
153
153
|
};
|
|
154
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* What to run to install the SDK, or null when there is nothing to: the
|
|
157
|
+
* Laravel files call the API with Laravel's own HTTP client and check the
|
|
158
|
+
* signature themselves, since perly/perly-pay is not on Packagist yet.
|
|
159
|
+
*/
|
|
155
160
|
export function installCommand(project) {
|
|
156
161
|
switch (project.packageManager) {
|
|
157
162
|
case "composer":
|
|
158
|
-
return
|
|
163
|
+
return null;
|
|
159
164
|
case "pnpm":
|
|
160
165
|
return "pnpm add perly-pay";
|
|
161
166
|
case "yarn":
|
package/dist/cli/env.js
CHANGED
|
@@ -6,6 +6,30 @@ export const KEY_VAR = "PERLY_PAY_SECRET_KEY";
|
|
|
6
6
|
export const WEBHOOK_SECRET_VAR = "PERLY_PAY_WEBHOOK_SECRET";
|
|
7
7
|
export const API_VAR = "PERLY_PAY_API";
|
|
8
8
|
export const DEFAULT_API = "https://pay.perly.io";
|
|
9
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
10
|
+
/**
|
|
11
|
+
* `--api` as an origin, or null. https, or plain http only to this machine
|
|
12
|
+
* (as perly-pay-mcp's PERLY_PAY_API): init sends the key there, and a key
|
|
13
|
+
* never crosses a network unencrypted. No user name, path, query or fragment,
|
|
14
|
+
* so `https://pay.perly.io@evil.example` cannot pass for Perly Pay.
|
|
15
|
+
*/
|
|
16
|
+
export function checkApiOrigin(raw) {
|
|
17
|
+
let url;
|
|
18
|
+
try {
|
|
19
|
+
url = new URL(raw.trim().replace(/\/+$/, ""));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== "/")
|
|
25
|
+
return null;
|
|
26
|
+
if (/[@?#\\]/.test(raw.trim().replace(/^https?:\/\//i, "")))
|
|
27
|
+
return null;
|
|
28
|
+
const local = LOCAL_HOSTS.has(url.hostname) || url.hostname.endsWith(".localhost");
|
|
29
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && local))
|
|
30
|
+
return null;
|
|
31
|
+
return url.origin;
|
|
32
|
+
}
|
|
9
33
|
const line = (name) => new RegExp(`^[ \\t]*(?:export[ \\t]+)?${name}[ \\t]*=(.*)$`, "m");
|
|
10
34
|
/** A variable's value in a dotenv file ("" when set empty), or null when absent. Quotes and a trailing comment are dropped. */
|
|
11
35
|
export function readEnvValue(content, name) {
|
package/dist/cli/init.js
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
* Everything outside the disk goes through `InitIO`, so the tests drive
|
|
7
7
|
* it without a terminal, a browser or a network.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join, sep } from "node:path";
|
|
11
12
|
import { PerlyPay } from "../index.js";
|
|
12
13
|
import { PerlyPayError } from "../errors.js";
|
|
13
14
|
import { DetectError, FRAMEWORK_NAMES, detectProject, installCommand } from "./detect.js";
|
|
14
|
-
import { API_VAR, DEFAULT_API, KEY_VAR, WEBHOOK_SECRET_VAR, checkTestKey, ensureIgnored, maskKey, readEnvValue, upsertEnv, } from "./env.js";
|
|
15
|
+
import { API_VAR, DEFAULT_API, KEY_VAR, WEBHOOK_SECRET_VAR, checkApiOrigin, checkTestKey, ensureIgnored, maskKey, readEnvValue, upsertEnv, } from "./env.js";
|
|
15
16
|
import { DEFAULT_OFFER, planFiles } from "./templates.js";
|
|
16
17
|
/** The Claude Code plugin and the MCP server, as their packages are named. */
|
|
17
18
|
export const PLUGIN_NAME = "perly-pay";
|
|
@@ -75,6 +76,47 @@ async function registerWebhook(url, key, api, io, haveSecret) {
|
|
|
75
76
|
const made = await pay.webhookEndpoints.create({ url, enabled_events: ["invoice.fulfilled"], description: "npx perly-pay init" });
|
|
76
77
|
return { endpoint: made.id, secret: made.secret ?? null, how: "created" };
|
|
77
78
|
}
|
|
79
|
+
/** Whether git tracks the file: `git ls-files --error-unmatch` (no shell). Outside a repository, or without git, it does not. */
|
|
80
|
+
export function trackedByGit(dir, path) {
|
|
81
|
+
try {
|
|
82
|
+
return spawnSync("git", ["ls-files", "--error-unmatch", "--", path], { cwd: dir, stdio: "ignore", timeout: 10_000 }).status === 0;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Why init must not write `rel` in `root`, or null: it is a symbolic link, or a
|
|
90
|
+
* linked folder above it leads out of the project. init writes only real files
|
|
91
|
+
* inside the project, so a cloned repository's links cannot aim it elsewhere.
|
|
92
|
+
*/
|
|
93
|
+
function unsafeTarget(root, rel) {
|
|
94
|
+
const target = join(root, rel);
|
|
95
|
+
try {
|
|
96
|
+
if (lstatSync(target).isSymbolicLink())
|
|
97
|
+
return `${rel} is a symbolic link`;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Not there yet: its folder decides.
|
|
101
|
+
}
|
|
102
|
+
let rootReal;
|
|
103
|
+
try {
|
|
104
|
+
rootReal = realpathSync(root);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
for (let dir = dirname(target);; dir = dirname(dir)) {
|
|
110
|
+
try {
|
|
111
|
+
const real = realpathSync(dir);
|
|
112
|
+
return real === rootReal || real.startsWith(rootReal + sep) ? null : `${rel} would land outside the project, through a linked folder`;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
if (dirname(dir) === dir)
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
78
120
|
function language(project) {
|
|
79
121
|
return project.framework === "laravel" ? "php" : project.style === "ts" ? "typescript" : "javascript";
|
|
80
122
|
}
|
|
@@ -99,9 +141,9 @@ export async function init(options, io) {
|
|
|
99
141
|
return result;
|
|
100
142
|
}
|
|
101
143
|
async function run(options, io, result) {
|
|
102
|
-
const api = (options.api ?? DEFAULT_API)
|
|
103
|
-
if (
|
|
104
|
-
throw new InitError(`--api takes an origin, like ${DEFAULT_API} or http://localhost:3200.`);
|
|
144
|
+
const api = checkApiOrigin(options.api ?? DEFAULT_API);
|
|
145
|
+
if (!api)
|
|
146
|
+
throw new InitError(`--api takes an origin, like ${DEFAULT_API}: https, or plain http only for localhost (http://localhost:3200). The key is sent there.`);
|
|
105
147
|
const developersUrl = `${api}/dashboard/developers`;
|
|
106
148
|
const offer = options.offer ?? DEFAULT_OFFER;
|
|
107
149
|
const project = detectProject(options.dir, options.framework);
|
|
@@ -116,6 +158,24 @@ async function run(options, io, result) {
|
|
|
116
158
|
const envText = readText(envPath) ?? "";
|
|
117
159
|
const { key, source } = await resolveKey(options, io, envText, developersUrl);
|
|
118
160
|
result.key = key ? "set" : "placeholder";
|
|
161
|
+
// Nothing written yet: refuse a target that is a link, or a keys file git would commit.
|
|
162
|
+
const unsafe = [
|
|
163
|
+
project.envFile,
|
|
164
|
+
".gitignore",
|
|
165
|
+
...plan.files.filter((f) => options.force || readText(join(project.dir, f.path)) === null).map((f) => f.path),
|
|
166
|
+
]
|
|
167
|
+
.map((rel) => unsafeTarget(project.dir, rel))
|
|
168
|
+
.filter((why) => why !== null);
|
|
169
|
+
if (unsafe.length) {
|
|
170
|
+
throw new InitError(`init writes only real files inside the project: ${unsafe.join("; ")}. Replace the link with a real file or folder, then run init again.`);
|
|
171
|
+
}
|
|
172
|
+
const writesSecret = !!options.webhookUrl || source === "--key" || source === "pasted";
|
|
173
|
+
if ((io.tracked ?? trackedByGit)(project.dir, project.envFile)) {
|
|
174
|
+
if (writesSecret) {
|
|
175
|
+
throw new InitError(`${project.envFile} is tracked by git, so a key written there would be committed with it. Untrack it first: git rm --cached ${project.envFile} (the file stays on disk, and .gitignore then keeps it out). Then run init again.`);
|
|
176
|
+
}
|
|
177
|
+
result.notes.push(`${project.envFile} is tracked by git: keep keys out of it until it is untracked (git rm --cached ${project.envFile}).`);
|
|
178
|
+
}
|
|
119
179
|
// The network first: if Perly Pay refuses the endpoint, nothing has been written yet.
|
|
120
180
|
let webhookSecret = null;
|
|
121
181
|
result.webhook = null;
|
|
@@ -167,8 +227,8 @@ async function run(options, io, result) {
|
|
|
167
227
|
if (ignored !== null && !options.dryRun)
|
|
168
228
|
writeFileSync(gitignorePath, ignored);
|
|
169
229
|
const next = result.next;
|
|
170
|
-
|
|
171
|
-
|
|
230
|
+
const command = project.hasSdk ? null : installCommand(project);
|
|
231
|
+
if (command) {
|
|
172
232
|
if (options.install && !options.dryRun) {
|
|
173
233
|
const status = io.run(command, project.dir);
|
|
174
234
|
result.install = { command, ran: true, status };
|
package/dist/cli/main.js
CHANGED
|
@@ -33,7 +33,7 @@ Options
|
|
|
33
33
|
--force replace files and values that are already there
|
|
34
34
|
--plugin print how to add the Claude Code plugin
|
|
35
35
|
--mcp print the MCP server config (${"perly-pay-mcp"})
|
|
36
|
-
--api <origin> Perly Pay's address (default ${DEFAULT_API})
|
|
36
|
+
--api <origin> Perly Pay's address (default ${DEFAULT_API}; plain http only for localhost)
|
|
37
37
|
--dir <path> the project (default: here)
|
|
38
38
|
--dry-run say what would happen, write nothing
|
|
39
39
|
--json print the result as JSON
|
package/dist/cli/templates.js
CHANGED
|
@@ -359,7 +359,9 @@ ${s === "cjs" ? "\nmodule.exports = { perlyPayHandler };\n" : ""}`;
|
|
|
359
359
|
mount: `${importLine}\n// in http.createServer(async (req, res) => { … }):\nif (await perlyPayHandler(req, res)) return;`,
|
|
360
360
|
};
|
|
361
361
|
}
|
|
362
|
-
// ── Laravel (
|
|
362
|
+
// ── Laravel (plain HTTP) ──────────────────────────────────────────
|
|
363
|
+
// Laravel's own HTTP client (Guzzle underneath, already in every Laravel app) and the signature check
|
|
364
|
+
// written out: nothing to install, so the files work before perly/perly-pay is on Packagist.
|
|
363
365
|
const php = (s) => `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
364
366
|
function laravelFiles(offer) {
|
|
365
367
|
const config = `<?php
|
|
@@ -368,20 +370,21 @@ function laravelFiles(offer) {
|
|
|
368
370
|
return [
|
|
369
371
|
'secret_key' => env('${KEY_VAR}'),
|
|
370
372
|
'webhook_secret' => env('${WEBHOOK_SECRET_VAR}'),
|
|
371
|
-
'api' => env('${API_VAR}'),
|
|
373
|
+
'api' => env('${API_VAR}', 'https://pay.perly.io'),
|
|
372
374
|
];
|
|
373
375
|
`;
|
|
374
376
|
const controller = `<?php
|
|
375
377
|
|
|
376
378
|
namespace App\\Http\\Controllers;
|
|
377
379
|
|
|
380
|
+
use Illuminate\\Http\\Client\\RequestException;
|
|
378
381
|
use Illuminate\\Http\\Request;
|
|
379
|
-
use
|
|
380
|
-
use
|
|
381
|
-
use Perly\\Pay\\Webhook;
|
|
382
|
+
use Illuminate\\Support\\Facades\\Http;
|
|
383
|
+
use Illuminate\\Support\\Str;
|
|
382
384
|
|
|
383
385
|
/**
|
|
384
386
|
* Perly Pay: crypto payments straight to your wallet.
|
|
387
|
+
* Calls the REST API with Laravel's HTTP client: https://pay.perly.io/docs/api
|
|
385
388
|
* ${HEADER}
|
|
386
389
|
*/
|
|
387
390
|
class PerlyPayController extends Controller
|
|
@@ -392,6 +395,9 @@ class PerlyPayController extends Controller
|
|
|
392
395
|
'credits_100' => ['description' => '100 credits', 'amount' => 1000, 'currency' => '${offer.currency}', 'credits' => 100],
|
|
393
396
|
];
|
|
394
397
|
|
|
398
|
+
/** How old a webhook's signature may be, in seconds. */
|
|
399
|
+
private const TOLERANCE = 300;
|
|
400
|
+
|
|
395
401
|
/** Try it: a page with the button. */
|
|
396
402
|
public function button()
|
|
397
403
|
{
|
|
@@ -410,13 +416,21 @@ class PerlyPayController extends Controller
|
|
|
410
416
|
if ($request->filled('user')) {
|
|
411
417
|
$metadata['user'] = (string) $request->input('user');
|
|
412
418
|
}
|
|
413
|
-
$invoice =
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
'
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
419
|
+
$invoice = Http::withToken((string) config('perly-pay.secret_key'))
|
|
420
|
+
->acceptJson()
|
|
421
|
+
// The same key on a retry opens the same invoice, never a second one.
|
|
422
|
+
->withHeaders(['Idempotency-Key' => (string) Str::uuid()])
|
|
423
|
+
// A dropped connection, 429 or 5xx is tried again (twice); anything else is the answer.
|
|
424
|
+
->retry(2, 500, fn ($e) => ! $e instanceof RequestException || $e->response->status() === 429 || $e->response->serverError(), throw: false)
|
|
425
|
+
->post(rtrim((string) config('perly-pay.api'), '/').'/api/v1/invoices', [
|
|
426
|
+
'amount' => $plan['amount'],
|
|
427
|
+
'currency' => $plan['currency'],
|
|
428
|
+
'description' => $plan['description'],
|
|
429
|
+
'return_url' => url('/'),
|
|
430
|
+
'metadata' => $metadata,
|
|
431
|
+
])
|
|
432
|
+
->throw()
|
|
433
|
+
->json();
|
|
420
434
|
|
|
421
435
|
return redirect()->away($invoice['url'], 303);
|
|
422
436
|
}
|
|
@@ -428,11 +442,10 @@ class PerlyPayController extends Controller
|
|
|
428
442
|
if ($secret === '') {
|
|
429
443
|
return response('${WEBHOOK_SECRET_VAR} is not set.', 500);
|
|
430
444
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
return response($e->getMessage(), 400);
|
|
445
|
+
// The RAW body, not the parsed request.
|
|
446
|
+
$event = $this->verify($request->getContent(), $request->header('Perly-Signature'), $secret);
|
|
447
|
+
if ($event === null) {
|
|
448
|
+
return response('Bad signature.', 400);
|
|
436
449
|
}
|
|
437
450
|
if (($event['type'] ?? '') === 'invoice.fulfilled') {
|
|
438
451
|
$this->grantAccess($event['data']['object']);
|
|
@@ -452,11 +465,33 @@ class PerlyPayController extends Controller
|
|
|
452
465
|
logger()->info('[perly-pay] '.$invoice['id'].' fulfilled', $meta);
|
|
453
466
|
}
|
|
454
467
|
|
|
455
|
-
|
|
468
|
+
/**
|
|
469
|
+
* Perly-Signature is "t=<unix seconds>,v1=<hex>", v1 = HMAC-SHA256(secret, t.".".body).
|
|
470
|
+
* The event when a v1 matches and t is under five minutes old; null otherwise.
|
|
471
|
+
*/
|
|
472
|
+
private function verify(string $payload, ?string $header, string $secret): ?array
|
|
456
473
|
{
|
|
457
|
-
$
|
|
474
|
+
$timestamp = null;
|
|
475
|
+
$signatures = [];
|
|
476
|
+
foreach (explode(',', (string) $header) as $part) {
|
|
477
|
+
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
|
|
478
|
+
if ($key === 't' && preg_match('/^\\d{1,12}$/', $value) === 1) {
|
|
479
|
+
$timestamp = (int) $value;
|
|
480
|
+
} elseif ($key === 'v1' && preg_match('/^[0-9a-fA-F]{64}$/', $value) === 1) {
|
|
481
|
+
$signatures[] = strtolower($value);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if ($timestamp === null || $signatures === [] || abs(time() - $timestamp) > self::TOLERANCE) {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
$expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
|
|
488
|
+
$matched = false;
|
|
489
|
+
foreach ($signatures as $signature) {
|
|
490
|
+
$matched = hash_equals($expected, $signature) || $matched;
|
|
491
|
+
}
|
|
492
|
+
$event = $matched ? json_decode($payload, true) : null;
|
|
458
493
|
|
|
459
|
-
return
|
|
494
|
+
return is_array($event) ? $event : null;
|
|
460
495
|
}
|
|
461
496
|
}
|
|
462
497
|
`;
|
package/dist/http.d.ts
CHANGED
package/dist/http.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* errors (honouring Retry-After), and a timeout. Plain `fetch`.
|
|
6
6
|
*/
|
|
7
7
|
import { ConnectionError, PerlyPayError } from "./errors.js";
|
|
8
|
-
export const SDK_VERSION = "0.1.
|
|
8
|
+
export const SDK_VERSION = "0.1.1";
|
|
9
9
|
function uuid() {
|
|
10
10
|
if (typeof globalThis.crypto?.randomUUID === "function")
|
|
11
11
|
return globalThis.crypto.randomUUID();
|
package/dist/index.d.ts
CHANGED
|
@@ -244,7 +244,7 @@ export declare class PerlyPay {
|
|
|
244
244
|
readonly webhookEndpoints: WebhookEndpoints;
|
|
245
245
|
readonly webhooks: typeof webhooks;
|
|
246
246
|
constructor(secretKey: string, options?: PerlyPayOptions);
|
|
247
|
-
static readonly VERSION = "0.1.
|
|
247
|
+
static readonly VERSION = "0.1.1";
|
|
248
248
|
}
|
|
249
249
|
/**
|
|
250
250
|
* License checks from YOUR app (a desktop app, a plugin, a CLI) — no
|
package/package.json
CHANGED