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 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) on Composer, the same calls and the same webhook check
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
 
@@ -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 "composer require perly/perly-pay";
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 { mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
- import { dirname, join } from "node:path";
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).replace(/\/+$/, "");
103
- if (!/^https?:\/\/[^/]+$/.test(api))
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
- if (!project.hasSdk) {
171
- const command = installCommand(project);
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
@@ -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 (PHP SDK) ─────────────────────────────────────────────
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 Perly\\Pay\\Exception\\SignatureVerificationException;
380
- use Perly\\Pay\\PerlyPay;
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 = $this->client()->invoices->create([
414
- 'amount' => $plan['amount'],
415
- 'currency' => $plan['currency'],
416
- 'description' => $plan['description'],
417
- 'return_url' => url('/'),
418
- 'metadata' => $metadata,
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
- try {
432
- // The RAW body, not the parsed request.
433
- $event = Webhook::verify($request->getContent(), $request->header('Perly-Signature'), $secret);
434
- } catch (SignatureVerificationException $e) {
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
- private function client(): PerlyPay
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
- $api = config('perly-pay.api');
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 new PerlyPay((string) config('perly-pay.secret_key'), $api ? ['base_url' => $api] : []);
494
+ return is_array($event) ? $event : null;
460
495
  }
461
496
  }
462
497
  `;
package/dist/http.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const SDK_VERSION = "0.1.0";
1
+ export declare const SDK_VERSION = "0.1.1";
2
2
  export interface HttpOptions {
3
3
  /** Empty for the public endpoints (license checks from your app). */
4
4
  secretKey: string;
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.0";
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.0";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perly-pay",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Perly Pay's server SDK: crypto payments straight to your wallet. Zero dependencies, fetch-based, typed.",
5
5
  "license": "MIT",
6
6
  "author": "Perly (https://pay.perly.io)",