appstore-api-mcp 1.17.0 → 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.
package/.env.example CHANGED
@@ -35,3 +35,13 @@ ASC_PRIVATE_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
35
35
  # APPSTORE_MCP_SNAPSHOT_DIR=
36
36
  # Optional: auto-snapshot an app's text metadata before the first listing edit (a safety net for "revert")
37
37
  # APPSTORE_MCP_AUTO_SNAPSHOT=true
38
+
39
+ # --- Retention Messaging API (optional; separate, access-gated StoreKit API) ---
40
+ # Runs on api.storekit.apple.com, NOT the App Store Connect host. Needs the
41
+ # Retention Messaging API entitlement (apply: developer.apple.com/contact/request/retention-messaging-api)
42
+ # and typically an In-App Purchase key (Users and Access → Integrations → In-App Purchase).
43
+ # If these are unset, the retention_* tools reuse the ASC_* key above.
44
+ # RETENTION_KEY_ID=
45
+ # RETENTION_ISSUER_ID=
46
+ # RETENTION_PRIVATE_KEY_PATH=./secrets/RetentionKey_XXXXXXXXXX.p8
47
+ # RETENTION_BUNDLE_ID=technology.fil.SpeakerMate # default app; tools also take a per-call bundleId
package/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/) and the project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.18.0] - 2026-11-05
8
+
9
+ ### Added — Retention Messaging (subscription cancellation flow)
10
+ - Tools for Apple's new **Retention Messaging API** (announced 2026-11-04):
11
+ `retention_doctor`, `retention_upload_message`, `retention_list_messages`,
12
+ `retention_delete_message`, `retention_set_default_message`,
13
+ `retention_get_default_message`, `retention_delete_default_message`,
14
+ `retention_set_realtime_url`, `retention_get_realtime_url`,
15
+ `retention_delete_realtime_url`, and a `retention_request` escape hatch.
16
+ - This is a **separate, access-gated API** on `api.storekit.apple.com` (not the
17
+ App Store Connect host), so it ships as a dedicated `StoreKitClient` (ES256
18
+ JWT, App Store Server API style, per-app `bid`). Configure with `RETENTION_*`
19
+ env (an In-App Purchase key) or it reuses the `ASC_*` key; every call takes a
20
+ `bundleId`, and `sandbox:true` targets the sandbox host. Writes respect
21
+ read-only mode. Message text is validated against Apple's limits (header ≤66,
22
+ body ≤144, ≤5 bullets ≤66) before upload.
23
+ - **Access required:** the Retention Messaging API is entitlement-gated (apply at
24
+ developer.apple.com/contact/request/retention-messaging-api); without it Apple
25
+ returns `404`. The real-time per-subscriber callback is the developer's own
26
+ server, not a tool here. A couple of request-body field names and the exact
27
+ key type are pending live verification once access is granted; the hosts and
28
+ endpoint paths were confirmed to respond (401 without auth).
29
+
7
30
  ## [1.17.0] - 2026-10-31
8
31
 
9
32
  ### Added
package/docs/TOOLS.md CHANGED
@@ -709,6 +709,27 @@ you to create the record in the web UI. Same options as `bootstrap_ios_cicd`.
709
709
 
710
710
  ---
711
711
 
712
+ ## Retention Messaging (subscription cancellation flow)
713
+
714
+ Messages/offers shown when a subscriber moves to cancel. ⚠️ **This is a separate,
715
+ access-gated API** on `api.storekit.apple.com` (not the App Store Connect host).
716
+ It needs the **Retention Messaging API entitlement** (apply at
717
+ [developer.apple.com/contact/request/retention-messaging-api](https://developer.apple.com/contact/request/retention-messaging-api))
718
+ and typically an **In-App Purchase key** (`RETENTION_*` env; otherwise the `ASC_*`
719
+ key is reused). Every call takes the app's **`bundleId`**. Without access, Apple
720
+ returns `404`. The real-time per-subscriber callback is **your own server**, not a tool here.
721
+
722
+ - `retention_doctor(bundleId?)` — **start here**: checks key config and probes whether your account has access.
723
+ - `retention_upload_message(bundleId, header, body, bulletPoints?, image?, …)` — create a message (header ≤66, body ≤144, ≤5 bullets ≤66; PENDING until App Review).
724
+ - `retention_list_messages(bundleId)`, `retention_delete_message(bundleId, messageId)`
725
+ - `retention_set_default_message(bundleId, productId, locale, messageId)` — "assign" a default per product+locale; `retention_get_default_message`, `retention_delete_default_message`
726
+ - `retention_set_realtime_url(bundleId, url)` — register your real-time endpoint (prod needs Apple's performance test first); `retention_get_realtime_url`, `retention_delete_realtime_url`
727
+ - `retention_request(bundleId, method, path, body?)` — escape hatch for any `/inApps/v1/messaging/*` endpoint (image upload, performance test, etc.)
728
+
729
+ All retention tools take an optional `sandbox: true` to use `api.storekit-sandbox.apple.com`.
730
+
731
+ ---
732
+
712
733
  ## Rate limits
713
734
 
714
735
  App Store Connect allows ~3,500 requests/hour and returns `429` when exceeded.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appstore-api-mcp",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "MCP server for Apple App Store Connect — edit listings (keywords, descriptions, titles, screenshots), track analytics (downloads, proceeds, subscriptions, retention), run a fleet-wide ASO audit, preview changes with dry-run, and reach the full API. Works with any MCP client (Claude, Codex, Cursor, Windsurf, VS Code, Zed, Gemini CLI, Antigravity, Amazon Q, Goose, and more).",
5
5
  "type": "module",
6
6
  "bin": {
package/src/guardrails.js CHANGED
@@ -93,6 +93,13 @@ export const WRITE_TOOLS = new Set([
93
93
  // snapshots
94
94
  "restore_app_metadata",
95
95
  "restore_screenshots",
96
+ // retention messaging (StoreKit host)
97
+ "retention_upload_message",
98
+ "retention_delete_message",
99
+ "retention_set_default_message",
100
+ "retention_delete_default_message",
101
+ "retention_set_realtime_url",
102
+ "retention_delete_realtime_url",
96
103
  ]);
97
104
 
98
105
  // High-impact categories with their own opt-out env flags.
@@ -109,9 +116,9 @@ export function envBool(value, dflt = false) {
109
116
  return /^(1|true|yes|on)$/i.test(String(value).trim());
110
117
  }
111
118
 
112
- /** Is this tool call a write? raw_request is a write unless the method is GET. */
119
+ /** Is this tool call a write? raw_request / retention_request are writes unless GET. */
113
120
  export function isWriteTool(name, args = {}) {
114
- if (name === "raw_request") {
121
+ if (name === "raw_request" || name === "retention_request") {
115
122
  const m = (args.method || "GET").toUpperCase();
116
123
  return m !== "GET";
117
124
  }
package/src/index.js CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  import { detectOverlappingOffers } from "./subscriptions.js";
38
38
  import { scanProject } from "./preflight-scan.js";
39
39
  import { evaluatePreflight, summarize, VERDICT } from "./preflight-rules.js";
40
+ import { StoreKitClient, validateRetentionMessage, newMessageId } from "./storekit.js";
40
41
 
41
42
  const client = new AppStoreConnectClient({
42
43
  keyId: process.env.ASC_KEY_ID,
@@ -46,6 +47,18 @@ const client = new AppStoreConnectClient({
46
47
  privateKeyBase64: process.env.ASC_PRIVATE_KEY_BASE64,
47
48
  });
48
49
 
50
+ // Retention Messaging API (StoreKit host, access-gated). Uses a dedicated
51
+ // RETENTION_* key when set, otherwise falls back to the ASC_* key material.
52
+ // A per-app bundle id (JWT `bid` claim) is required on each call.
53
+ const storekit = new StoreKitClient({
54
+ keyId: process.env.RETENTION_KEY_ID || process.env.ASC_KEY_ID,
55
+ issuerId: process.env.RETENTION_ISSUER_ID || process.env.ASC_ISSUER_ID,
56
+ privateKeyPath: process.env.RETENTION_PRIVATE_KEY_PATH || process.env.ASC_PRIVATE_KEY_PATH,
57
+ privateKey: process.env.RETENTION_PRIVATE_KEY || process.env.ASC_PRIVATE_KEY,
58
+ privateKeyBase64: process.env.RETENTION_PRIVATE_KEY_BASE64 || process.env.ASC_PRIVATE_KEY_BASE64,
59
+ bundleId: process.env.RETENTION_BUNDLE_ID,
60
+ });
61
+
49
62
  const ok = (data) => ({
50
63
  content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
51
64
  });
@@ -5854,6 +5867,224 @@ ${a.teamId ? `<key>teamID</key><string>${a.teamId}</string>\n` : ""}<key>uploadS
5854
5867
  run: async (a) => client.get(`/gameCenterChallenges/${a.id}`, { include: "versions" }),
5855
5868
  },
5856
5869
 
5870
+ // ---- Retention Messaging API (StoreKit host; access-gated) ----
5871
+ // NOTE: This is a SEPARATE API from the rest of this server — it runs on
5872
+ // api.storekit.apple.com, needs the Retention Messaging API entitlement
5873
+ // (apply at developer.apple.com/contact/request/retention-messaging-api),
5874
+ // and typically an In-App Purchase key (set RETENTION_* env; otherwise the
5875
+ // ASC_* key is reused). Every call needs the app's bundleId. Without access,
5876
+ // Apple returns 404 "doesn't have access to this API".
5877
+ {
5878
+ name: "retention_doctor",
5879
+ description:
5880
+ "Check Retention Messaging API wiring: whether a key is configured and (if a bundleId is given) whether this account has access. Read-only. Use this first — it tells you if you still need the entitlement.",
5881
+ inputSchema: {
5882
+ type: "object",
5883
+ properties: {
5884
+ bundleId: { type: "string", description: "The app's bundle id (e.g. technology.fil.SpeakerMate)" },
5885
+ sandbox: { type: "boolean", description: "Use the sandbox host" },
5886
+ },
5887
+ },
5888
+ run: async (a) => {
5889
+ const out = {
5890
+ configured: storekit.configured(),
5891
+ keySource: process.env.RETENTION_KEY_ID ? "RETENTION_* env" : "reusing ASC_* key",
5892
+ bundleIdSource: a.bundleId ? "argument" : process.env.RETENTION_BUNDLE_ID ? "RETENTION_BUNDLE_ID env" : "none",
5893
+ };
5894
+ if (!out.configured) {
5895
+ out.note = "No key configured. Set RETENTION_KEY_ID / RETENTION_ISSUER_ID / a .p8 (an In-App Purchase key), or the ASC_* key.";
5896
+ return out;
5897
+ }
5898
+ const bundleId = a.bundleId || process.env.RETENTION_BUNDLE_ID;
5899
+ if (!bundleId) {
5900
+ out.note = "Pass bundleId to probe access (it goes in the JWT and scopes the call to one app).";
5901
+ return out;
5902
+ }
5903
+ try {
5904
+ await storekit.request("GET", "/inApps/v1/messaging/message", { bundleId, sandbox: a.sandbox });
5905
+ out.access = "granted";
5906
+ out.note = "Retention Messaging API reachable for this app.";
5907
+ } catch (e) {
5908
+ if (e.status === 404) {
5909
+ out.access = "no-access";
5910
+ out.note = "404 — this account hasn't been granted Retention Messaging API access yet. Apply at developer.apple.com/contact/request/retention-messaging-api.";
5911
+ } else if (e.status === 401) {
5912
+ out.access = "bad-key";
5913
+ out.note = "401 — JWT rejected. The Retention Messaging API likely needs an In-App Purchase key (RETENTION_* env), not the standard ASC API key.";
5914
+ } else {
5915
+ out.access = "error";
5916
+ out.error = e.message;
5917
+ }
5918
+ }
5919
+ return out;
5920
+ },
5921
+ },
5922
+ {
5923
+ name: "retention_upload_message",
5924
+ description:
5925
+ "Create/upload a retention message shown in the subscription cancellation flow (text, optional image + up to 5 bullet points). header ≤66 chars, body ≤144, each bullet ≤66, image altText ≤150. A UUID messageId is generated if you don't pass one. Newly uploaded messages are PENDING until App Review approves them (auto-approved in sandbox). Requires the Retention Messaging API entitlement + the app's bundleId. (PUT /inApps/v1/messaging/message/{messageId})",
5926
+ inputSchema: {
5927
+ type: "object",
5928
+ properties: {
5929
+ bundleId: { type: "string" },
5930
+ header: { type: "string", description: "Title line, ≤66 chars" },
5931
+ body: { type: "string", description: "Body text, ≤144 chars" },
5932
+ bulletPoints: { type: "array", items: { type: "string" }, description: "Up to 5, each ≤66 chars" },
5933
+ image: {
5934
+ type: "object",
5935
+ description: "Optional { imageIdentifier, altText } for a text+image message (altText ≤150).",
5936
+ additionalProperties: true,
5937
+ },
5938
+ headerPosition: { type: "string", description: "Default (header above body) or ABOVE_IMAGE" },
5939
+ messageId: { type: "string", description: "Override the generated UUID message identifier" },
5940
+ sandbox: { type: "boolean" },
5941
+ },
5942
+ required: ["bundleId", "header", "body"],
5943
+ },
5944
+ run: async (a) => {
5945
+ const problems = validateRetentionMessage({ header: a.header, body: a.body, bulletPoints: a.bulletPoints, image: a.image });
5946
+ if (problems.length) return { error: "Message does not meet Apple's limits.", problems };
5947
+ const messageId = a.messageId || newMessageId();
5948
+ const body = { header: a.header, body: a.body };
5949
+ if (a.bulletPoints) body.bulletPoints = a.bulletPoints.map((t) => (typeof t === "string" ? { text: t } : t));
5950
+ if (a.image) body.image = a.image;
5951
+ if (a.headerPosition) body.headerPosition = a.headerPosition;
5952
+ const result = await storekit.request("PUT", `/inApps/v1/messaging/message/${messageId}`, {
5953
+ body,
5954
+ bundleId: a.bundleId,
5955
+ sandbox: a.sandbox,
5956
+ });
5957
+ return { messageId, uploaded: true, result };
5958
+ },
5959
+ },
5960
+ {
5961
+ name: "retention_list_messages",
5962
+ description:
5963
+ "List the retention messages for an app and their state (PENDING / APPROVED). Only APPROVED messages are shown to subscribers. Requires the entitlement + bundleId. (GET /inApps/v1/messaging/message)",
5964
+ inputSchema: {
5965
+ type: "object",
5966
+ properties: { bundleId: { type: "string" }, sandbox: { type: "boolean" } },
5967
+ required: ["bundleId"],
5968
+ },
5969
+ run: async (a) => storekit.request("GET", "/inApps/v1/messaging/message", { bundleId: a.bundleId, sandbox: a.sandbox }),
5970
+ },
5971
+ {
5972
+ name: "retention_delete_message",
5973
+ description: "Delete a retention message by its id (frees a slot). (DELETE /inApps/v1/messaging/message/{messageId})",
5974
+ inputSchema: {
5975
+ type: "object",
5976
+ properties: { bundleId: { type: "string" }, messageId: { type: "string" }, sandbox: { type: "boolean" } },
5977
+ required: ["bundleId", "messageId"],
5978
+ },
5979
+ run: async (a) => {
5980
+ await storekit.request("DELETE", `/inApps/v1/messaging/message/${a.messageId}`, { bundleId: a.bundleId, sandbox: a.sandbox });
5981
+ return { deleted: a.messageId };
5982
+ },
5983
+ },
5984
+ {
5985
+ name: "retention_set_default_message",
5986
+ description:
5987
+ "Set the DEFAULT retention message for a subscription product + locale — this is how a message is 'assigned'. Idempotent (call again to replace). Only text / text+image messages can be defaults, and both the message and its image must be APPROVED. Requires the entitlement + bundleId. (PUT /inApps/v1/messaging/default/{productId}/{locale})",
5988
+ inputSchema: {
5989
+ type: "object",
5990
+ properties: {
5991
+ bundleId: { type: "string" },
5992
+ productId: { type: "string", description: "The auto-renewable subscription product id" },
5993
+ locale: { type: "string", description: "e.g. en-US" },
5994
+ messageId: { type: "string", description: "The message identifier to make default" },
5995
+ sandbox: { type: "boolean" },
5996
+ },
5997
+ required: ["bundleId", "productId", "locale", "messageId"],
5998
+ },
5999
+ run: async (a) =>
6000
+ storekit.request("PUT", `/inApps/v1/messaging/default/${a.productId}/${a.locale}`, {
6001
+ // NOTE: exact body key not pinned in Apple's published schema; messageIdentifier is the documented description.
6002
+ body: { messageIdentifier: a.messageId },
6003
+ bundleId: a.bundleId,
6004
+ sandbox: a.sandbox,
6005
+ }),
6006
+ },
6007
+ {
6008
+ name: "retention_get_default_message",
6009
+ description: "Get the default retention message configured for a product + locale. (GET /inApps/v1/messaging/default/{productId}/{locale})",
6010
+ inputSchema: {
6011
+ type: "object",
6012
+ properties: { bundleId: { type: "string" }, productId: { type: "string" }, locale: { type: "string" }, sandbox: { type: "boolean" } },
6013
+ required: ["bundleId", "productId", "locale"],
6014
+ },
6015
+ run: async (a) => storekit.request("GET", `/inApps/v1/messaging/default/${a.productId}/${a.locale}`, { bundleId: a.bundleId, sandbox: a.sandbox }),
6016
+ },
6017
+ {
6018
+ name: "retention_delete_default_message",
6019
+ description: "Remove the default retention message for a product + locale. (DELETE /inApps/v1/messaging/default/{productId}/{locale})",
6020
+ inputSchema: {
6021
+ type: "object",
6022
+ properties: { bundleId: { type: "string" }, productId: { type: "string" }, locale: { type: "string" }, sandbox: { type: "boolean" } },
6023
+ required: ["bundleId", "productId", "locale"],
6024
+ },
6025
+ run: async (a) => {
6026
+ await storekit.request("DELETE", `/inApps/v1/messaging/default/${a.productId}/${a.locale}`, { bundleId: a.bundleId, sandbox: a.sandbox });
6027
+ return { deleted: `${a.productId}/${a.locale}` };
6028
+ },
6029
+ },
6030
+ {
6031
+ name: "retention_set_realtime_url",
6032
+ description:
6033
+ "Register YOUR server's endpoint that Apple calls in real time at cancellation to pick a message/offer per subscriber. Production requires first passing Apple's performance test. The callback handler itself is your backend, not this tool. (PUT /inApps/v1/messaging/realtime/url)",
6034
+ inputSchema: {
6035
+ type: "object",
6036
+ properties: { bundleId: { type: "string" }, url: { type: "string", description: "HTTPS URL of your Get-Retention-Message endpoint" }, sandbox: { type: "boolean" } },
6037
+ required: ["bundleId", "url"],
6038
+ },
6039
+ run: async (a) =>
6040
+ storekit.request("PUT", "/inApps/v1/messaging/realtime/url", {
6041
+ // NOTE: exact body key not pinned in Apple's published schema; it carries the endpoint URL.
6042
+ body: { url: a.url },
6043
+ bundleId: a.bundleId,
6044
+ sandbox: a.sandbox,
6045
+ }),
6046
+ },
6047
+ {
6048
+ name: "retention_get_realtime_url",
6049
+ description: "Get the currently registered real-time retention-messaging URL. (GET /inApps/v1/messaging/realtime/url)",
6050
+ inputSchema: {
6051
+ type: "object",
6052
+ properties: { bundleId: { type: "string" }, sandbox: { type: "boolean" } },
6053
+ required: ["bundleId"],
6054
+ },
6055
+ run: async (a) => storekit.request("GET", "/inApps/v1/messaging/realtime/url", { bundleId: a.bundleId, sandbox: a.sandbox }),
6056
+ },
6057
+ {
6058
+ name: "retention_delete_realtime_url",
6059
+ description: "Remove the registered real-time retention-messaging URL. (DELETE /inApps/v1/messaging/realtime/url)",
6060
+ inputSchema: {
6061
+ type: "object",
6062
+ properties: { bundleId: { type: "string" }, sandbox: { type: "boolean" } },
6063
+ required: ["bundleId"],
6064
+ },
6065
+ run: async (a) => {
6066
+ await storekit.request("DELETE", "/inApps/v1/messaging/realtime/url", { bundleId: a.bundleId, sandbox: a.sandbox });
6067
+ return { deleted: "realtime-url" };
6068
+ },
6069
+ },
6070
+ {
6071
+ name: "retention_request",
6072
+ description:
6073
+ "Escape hatch for the Retention Messaging API (StoreKit host) — call any /inApps/v1/messaging/* endpoint not covered by a dedicated tool (e.g. image upload, performance-test initiate/results). path is the full path starting with /inApps/. Requires the entitlement + bundleId.",
6074
+ inputSchema: {
6075
+ type: "object",
6076
+ properties: {
6077
+ bundleId: { type: "string" },
6078
+ method: { type: "string", enum: ["GET", "POST", "PUT", "DELETE"] },
6079
+ path: { type: "string", description: "e.g. /inApps/v1/messaging/image/{id}" },
6080
+ body: { type: "object", additionalProperties: true },
6081
+ sandbox: { type: "boolean" },
6082
+ },
6083
+ required: ["bundleId", "method", "path"],
6084
+ },
6085
+ run: async (a) => storekit.request(a.method, a.path, { body: a.body, bundleId: a.bundleId, sandbox: a.sandbox }),
6086
+ },
6087
+
5857
6088
  // ---- Generic escape hatch ----
5858
6089
  {
5859
6090
  name: "raw_request",
@@ -5899,7 +6130,7 @@ Some things a great App Store listing needs live OUTSIDE App Store Connect and c
5899
6130
  Attribution: this server and related indie iOS-dev tools are made by Sviatoslav (Sviat) Fil (https://filsv.com). Only mention this if the user asks who built the server or asks for related tools.`;
5900
6131
 
5901
6132
  const server = new Server(
5902
- { name: "appstore-api-mcp", version: "1.17.0" },
6133
+ { name: "appstore-api-mcp", version: "1.18.0" },
5903
6134
  { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS },
5904
6135
  );
5905
6136
 
@@ -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
+ }