appstore-api-mcp 1.16.4 → 1.18.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.
@@ -0,0 +1,136 @@
1
+ // Minimal client for Apple's StoreKit-host APIs (api.storekit.apple.com),
2
+ // used here for the Retention Messaging API. This is a DIFFERENT host, auth and
3
+ // wire format from the App Store Connect JSON:API (see client.js):
4
+ // - host: api.storekit.apple.com (prod) / api.storekit-sandbox.apple.com
5
+ // - auth: ES256 JWT, App Store Server API style (aud "appstoreconnect-v1",
6
+ // plus a `bid` bundle-id claim) — typically an In-App Purchase key
7
+ // - body: plain JSON (NOT the data/type/attributes JSON:API envelope)
8
+ //
9
+ // The Retention Messaging API is access-gated (pre-release, per-app approval).
10
+ // Without the entitlement, calls return 404 "doesn't have access to this API".
11
+
12
+ import { readFileSync } from "node:fs";
13
+ import { randomUUID } from "node:crypto";
14
+ import { importPKCS8, SignJWT } from "jose";
15
+
16
+ export const STOREKIT_HOSTS = {
17
+ production: "https://api.storekit.apple.com",
18
+ sandbox: "https://api.storekit-sandbox.apple.com",
19
+ };
20
+
21
+ export class StoreKitClient {
22
+ constructor({ keyId, issuerId, privateKey, privateKeyBase64, privateKeyPath, bundleId } = {}) {
23
+ this.keyId = keyId;
24
+ this.issuerId = issuerId;
25
+ this.defaultBundleId = bundleId || null;
26
+ this._pem =
27
+ privateKey ||
28
+ (privateKeyBase64 ? Buffer.from(privateKeyBase64, "base64").toString("utf8") : null) ||
29
+ (privateKeyPath ? readFileSync(privateKeyPath, "utf8") : null);
30
+ }
31
+
32
+ /** True when a key id, issuer id and private key are all present. */
33
+ configured() {
34
+ return !!(this.keyId && this.issuerId && this._pem);
35
+ }
36
+
37
+ async _token(bundleId) {
38
+ if (!this.configured())
39
+ throw new Error(
40
+ "Retention Messaging API is not configured. Set RETENTION_KEY_ID, RETENTION_ISSUER_ID and a key " +
41
+ "(RETENTION_PRIVATE_KEY_PATH / RETENTION_PRIVATE_KEY / RETENTION_PRIVATE_KEY_BASE64) — typically an " +
42
+ "In-App Purchase key — or let it reuse the ASC_* key. A per-app bundleId is also required.",
43
+ );
44
+ const bid = bundleId || this.defaultBundleId;
45
+ if (!bid)
46
+ throw new Error(
47
+ "bundleId is required for the Retention Messaging API (it goes in the JWT `bid` claim). Pass bundleId, or set RETENTION_BUNDLE_ID.",
48
+ );
49
+ const now = Math.floor(Date.now() / 1000);
50
+ const key = await importPKCS8(this._pem, "ES256");
51
+ return new SignJWT({ bid })
52
+ .setProtectedHeader({ alg: "ES256", kid: this.keyId, typ: "JWT" })
53
+ .setIssuedAt(now)
54
+ .setIssuer(this.issuerId)
55
+ .setExpirationTime(now + 19 * 60)
56
+ .setAudience("appstoreconnect-v1")
57
+ .sign(key);
58
+ }
59
+
60
+ /**
61
+ * Make a request against the StoreKit host. `path` starts with /inApps/...
62
+ * opts: { body, bundleId, sandbox }.
63
+ */
64
+ async request(method, path, { body, bundleId, sandbox } = {}) {
65
+ const host = sandbox ? STOREKIT_HOSTS.sandbox : STOREKIT_HOSTS.production;
66
+ const token = await this._token(bundleId);
67
+ const headers = { Authorization: `Bearer ${token}` };
68
+ let payload;
69
+ if (body !== undefined) {
70
+ headers["Content-Type"] = "application/json";
71
+ payload = JSON.stringify(body);
72
+ }
73
+ const res = await fetch(host + path, { method, headers, body: payload });
74
+ const text = await res.text();
75
+ let data = null;
76
+ if (text) {
77
+ try {
78
+ data = JSON.parse(text);
79
+ } catch {
80
+ data = text;
81
+ }
82
+ }
83
+ if (!res.ok) {
84
+ const detail = typeof data === "string" ? data : data ? JSON.stringify(data) : "";
85
+ const err = new Error(
86
+ `Retention Messaging API ${method} ${path} failed: ${res.status} ${res.statusText}${detail ? " — " + detail : ""}`,
87
+ );
88
+ err.status = res.status;
89
+ err.body = data;
90
+ throw err;
91
+ }
92
+ return data ?? { ok: true, status: res.status };
93
+ }
94
+ }
95
+
96
+ // ---- Pure helpers (unit-testable) ----
97
+
98
+ // Apple's documented limits for a retention message.
99
+ export const RETENTION_LIMITS = {
100
+ header: 66,
101
+ body: 144,
102
+ bulletPoints: 5,
103
+ bulletText: 66,
104
+ imageAltText: 150,
105
+ };
106
+
107
+ /**
108
+ * Validate the text of a retention message against Apple's limits. Returns an
109
+ * array of human-readable problems (empty = OK). Does not throw.
110
+ */
111
+ export function validateRetentionMessage({ header, body, bulletPoints, image } = {}) {
112
+ const problems = [];
113
+ if (!header || !header.trim()) problems.push("header is required.");
114
+ else if (header.length > RETENTION_LIMITS.header)
115
+ problems.push(`header is ${header.length} chars; max is ${RETENTION_LIMITS.header}.`);
116
+ if (!body || !body.trim()) problems.push("body is required.");
117
+ else if (body.length > RETENTION_LIMITS.body)
118
+ problems.push(`body is ${body.length} chars; max is ${RETENTION_LIMITS.body}.`);
119
+ if (Array.isArray(bulletPoints)) {
120
+ if (bulletPoints.length > RETENTION_LIMITS.bulletPoints)
121
+ problems.push(`${bulletPoints.length} bullet points; max is ${RETENTION_LIMITS.bulletPoints}.`);
122
+ bulletPoints.forEach((b, i) => {
123
+ const t = typeof b === "string" ? b : b?.text;
124
+ if (t && t.length > RETENTION_LIMITS.bulletText)
125
+ problems.push(`bullet ${i + 1} is ${t.length} chars; max is ${RETENTION_LIMITS.bulletText}.`);
126
+ });
127
+ }
128
+ if (image && image.altText && image.altText.length > RETENTION_LIMITS.imageAltText)
129
+ problems.push(`image altText is ${image.altText.length} chars; max is ${RETENTION_LIMITS.imageAltText}.`);
130
+ return problems;
131
+ }
132
+
133
+ /** A fresh RFC-4122 v4 UUID for a message identifier. */
134
+ export function newMessageId() {
135
+ return randomUUID();
136
+ }