@yawlabs/lemonsqueezy-mcp 0.12.0 → 0.13.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 CHANGED
@@ -318,7 +318,15 @@ npm run check:containerfile # CI runs this; non-zero exit means the two have dr
318
318
 
319
319
  ## Running on oam.js (optional)
320
320
 
321
- [oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.8.2: full MCP handshake, all 64 tools, the `lemonsqueezy://audit-log` resource, working `fetch`, and guardrail rejections with error text identical to Node.
321
+ [oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.9.0: full MCP handshake, all 64 tools, the `lemonsqueezy://audit-log` resource, working `fetch`, and guardrail rejections with error text identical to Node.
322
+
323
+ **oam 0.9.0 is the minimum.** Older releases ran `child_process.execFile` arguments through a shell, which was reachable here whenever `LEMONSQUEEZY_API_KEY_COMMAND` is configured -- that feature shells out to fetch the key, and its arguments were re-split by a shell. The launcher enforces the floor: given an older oam it falls back to Node and says so on stderr, and `LEMONSQUEEZY_MCP_RUNTIME=oam` turns that into a hard error.
324
+
325
+ ### Sandboxing (opt-in)
326
+
327
+ Set `LEMONSQUEEZY_MCP_SANDBOX=1` to run under oam's `--permission` model: network restricted to `api.lemonsqueezy.com` (plus the host of `LEMONSQUEEZY_SINK_URL` when set), filesystem denied outright, and child-process denied unless `LEMONSQUEEZY_API_KEY_COMMAND` is configured.
328
+
329
+ It is opt-in rather than default because a wrong grant does not fail loudly. oam denies a non-granted environment variable by making it **absent** from `process.env` rather than throwing, so an under-granted `LEMONSQUEEZY_API_KEY` reads as "unauthenticated" rather than "denied". The env allow-list in the launcher is derived from what the shipped bundle actually reads -- if you add a new `process.env` lookup, extend that list with it.
322
330
 
323
331
  ```jsonc
324
332
  {
@@ -22,18 +22,48 @@
22
22
  * For an MCP host config, point straight at oam and skip this file:
23
23
  * { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
24
24
  *
25
+ * THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
26
+ * `LEMONSQUEEZY_MCP_SANDBOX=1` runs the server under oam's permission model:
27
+ * network limited to api.lemonsqueezy.com, filesystem denied outright.
28
+ *
29
+ * LEMONSQUEEZY_SINK_URL is operator-configured, so its host is parsed out and
30
+ * added to the grant when set rather than assumed. Child-process stays denied
31
+ * UNLESS LEMONSQUEEZY_API_KEY_COMMAND is configured -- that feature shells out
32
+ * to fetch the key, so the grant is tied to the feature instead of handed over
33
+ * unconditionally.
34
+ *
35
+ * Opt-in, not default, because a wrong grant does not fail loudly. oam denies a
36
+ * non-granted environment variable by making it ABSENT from process.env rather
37
+ * than throwing, so an under-granted LEMONSQUEEZY_API_KEY reads as
38
+ * "unauthenticated" rather than "denied". The env list is derived from what the
39
+ * shipped bundle actually reads -- keep it in step with the bundle.
40
+ *
41
+ * MINIMUM OAM VERSION
42
+ * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
43
+ * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at
44
+ * `maxBuffer` while reporting success, and `stdio: 'inherit'`/`'ignore'` both
45
+ * behaved as `'pipe'`. This server shells out only when
46
+ * LEMONSQUEEZY_API_KEY_COMMAND is configured, so the execFile bug was reachable
47
+ * on exactly that path -- the command's arguments were re-split by a shell.
48
+ * An older oam is not an error: the launcher falls back to Node and says so on
49
+ * stderr. Pinning the floor here is what makes that fallback automatic.
50
+ *
25
51
  * SELECTION
26
52
  * LEMONSQUEEZY_MCP_RUNTIME=oam require oam; fail loudly if it is missing
27
53
  * LEMONSQUEEZY_MCP_RUNTIME=node never use oam
28
54
  * LEMONSQUEEZY_MCP_RUNTIME=auto prefer oam, silently fall back (default)
55
+ * LEMONSQUEEZY_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
29
56
  * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
30
57
  */
31
58
 
32
- import { spawn } from "node:child_process";
59
+ import { execFileSync, spawn } from "node:child_process";
33
60
  import { existsSync } from "node:fs";
34
61
  import { constants, homedir } from "node:os";
35
62
  import { delimiter, join } from "node:path";
36
- import { fileURLToPath } from "node:url";
63
+ import { fileURLToPath } from "node:url";
64
+
65
+ /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
66
+ const OAM_MIN = [0, 9, 0];
37
67
 
38
68
  // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
39
69
  // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
@@ -83,6 +113,70 @@ function findOam() {
83
113
  return null;
84
114
  }
85
115
 
116
+ /**
117
+ * `oam --version` -> [major, minor, patch], or null when it cannot be read.
118
+ * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
119
+ */
120
+ function oamVersion(cmd) {
121
+ try {
122
+ const out = execFileSync(cmd, ["--version"], {
123
+ encoding: "utf-8",
124
+ stdio: ["ignore", "pipe", "ignore"],
125
+ });
126
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
127
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
128
+ } catch {
129
+ // Not executable, wrong arch, or deleted since the stat. Caller degrades.
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /** True when `v` is at least `min`, comparing major/minor/patch in order. */
135
+ function atLeast(v, min) {
136
+ if (!v) return false;
137
+ for (let i = 0; i < min.length; i++) {
138
+ if (v[i] > min[i]) return true;
139
+ if (v[i] < min[i]) return false;
140
+ }
141
+ return true;
142
+ }
143
+
144
+ /**
145
+ * The `--permission` grant list, or [] when the sandbox is not requested.
146
+ *
147
+ * These are oam's PROCESS-level flags: they belong before the `run` subcommand,
148
+ * not after it. `oam run --permission file.js` is rejected outright, which is a
149
+ * good failure but only because it is loud -- ordering here is load-bearing.
150
+ *
151
+ * Net grants prefix-match `host` for fetch and `host:port` for sockets.
152
+ * A denied environment variable is ABSENT from process.env rather than throwing,
153
+ * so the env list below is derived from what the bundle actually reads; trimming
154
+ * it produces silent misbehaviour, not a clear denial.
155
+ */
156
+ function sandboxFlags() {
157
+ if (process.env.LEMONSQUEEZY_MCP_SANDBOX !== "1") return [];
158
+
159
+ const hosts = ["api.lemonsqueezy.com"];
160
+ // LEMONSQUEEZY_SINK_URL is operator-configured, so its host has to be learned, not assumed.
161
+ if (process.env.LEMONSQUEEZY_SINK_URL) {
162
+ try {
163
+ const { hostname } = new URL(process.env.LEMONSQUEEZY_SINK_URL);
164
+ if (hostname && !hosts.includes(hostname)) hosts.push(hostname);
165
+ } catch {
166
+ // Malformed URL: the feature is already broken on its own terms; adding
167
+ // nothing here keeps the grant honest rather than guessing a host.
168
+ }
169
+ }
170
+ const netFlag = `--allow-net=${hosts.join(",")}`;
171
+
172
+ const env = ["LEMONSQUEEZY_ALLOWED_STORE_IDS","LEMONSQUEEZY_API_KEY","LEMONSQUEEZY_API_KEY_COMMAND","LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT","LEMONSQUEEZY_DISABLE_CLASSES","LEMONSQUEEZY_LOG","LEMONSQUEEZY_MAX_REFUND_AMOUNT_CENTS","LEMONSQUEEZY_RATE_LIMIT_PER_CLASS","LEMONSQUEEZY_SINK_ADMIN_TOKEN","LEMONSQUEEZY_SINK_URL","PATH"];
173
+
174
+ const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
175
+ // Tied to the feature that needs it, not granted unconditionally.
176
+ if (process.env.LEMONSQUEEZY_API_KEY_COMMAND) flags.push("--allow-child-process");
177
+ return flags;
178
+ }
179
+
86
180
  /** Run the server in THIS process. The zero-overhead fallback. */
87
181
  async function runInProcess() {
88
182
  // A server may gate its bootstrap on being the process ENTRY POINT --
@@ -123,7 +217,7 @@ if (mode === "node") {
123
217
  } else {
124
218
  // `--` separates oam's own flags from the script's argv, so `lemonsqueezy-mcp
125
219
  // --version` and any host-supplied flags survive the hop unchanged.
126
- const child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
220
+ const child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
127
221
  // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
128
222
  // stdin/stdout is untouched and the host's stdin-close still reaches the
129
223
  // server's shutdown path.
package/dist/index.js CHANGED
@@ -23793,7 +23793,7 @@ function readAuditLogResource(uri) {
23793
23793
  }
23794
23794
 
23795
23795
  // src/index.ts
23796
- var version2 = true ? "0.12.0" : createRequire(import.meta.url)("../package.json").version;
23796
+ var version2 = true ? "0.13.0" : createRequire(import.meta.url)("../package.json").version;
23797
23797
  var subcommand = process.argv[2];
23798
23798
  if (subcommand === "version" || subcommand === "--version") {
23799
23799
  console.log(version2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/lemonsqueezy-mcp",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "mcpName": "io.github.YawLabs/lemonsqueezy-mcp",
5
5
  "description": "LemonSqueezy MCP server for managing your store from AI assistants",
6
6
  "license": "MIT",