appstore-api-mcp 1.0.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 +20 -0
- package/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +265 -0
- package/docs/CLIENTS.md +167 -0
- package/docs/SECURITY.md +57 -0
- package/docs/SETUP.md +105 -0
- package/docs/TOOLS.md +239 -0
- package/package.json +53 -0
- package/src/client.js +162 -0
- package/src/index.js +815 -0
package/docs/TOOLS.md
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# Tool reference
|
|
2
|
+
|
|
3
|
+
Every tool the server exposes, with parameters and return shape. You normally
|
|
4
|
+
don't call these by hand — you ask the model in plain language and it picks the
|
|
5
|
+
right ones. This is for when you want the exact contract.
|
|
6
|
+
|
|
7
|
+
All tools return JSON (pretty-printed text). IDs are App Store Connect resource
|
|
8
|
+
ids (strings). Required params are marked **(required)**.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Apps
|
|
13
|
+
|
|
14
|
+
### `list_apps`
|
|
15
|
+
List all apps in the account.
|
|
16
|
+
- `limit` — max apps (default 100)
|
|
17
|
+
- `filterBundleId` — exact bundle id filter
|
|
18
|
+
- **Returns:** array of `{ id, name, bundleId, sku, primaryLocale, ... }`
|
|
19
|
+
|
|
20
|
+
### `get_app`
|
|
21
|
+
Get one app's details.
|
|
22
|
+
- `appId` **(required)**
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## App info — name, subtitle, privacy policy
|
|
27
|
+
|
|
28
|
+
These live on `appInfo` records, separate from version-specific copy.
|
|
29
|
+
|
|
30
|
+
### `list_app_infos`
|
|
31
|
+
List the appInfo records for an app. Usually one is editable.
|
|
32
|
+
- `appId` **(required)**
|
|
33
|
+
|
|
34
|
+
### `list_app_info_localizations`
|
|
35
|
+
List per-locale name/subtitle/privacy for an appInfo.
|
|
36
|
+
- `appInfoId` **(required)**
|
|
37
|
+
- **Returns:** array of `{ id, locale, name, subtitle, privacyPolicyUrl, privacyPolicyText }`
|
|
38
|
+
|
|
39
|
+
### `update_app_info_localization`
|
|
40
|
+
Update name/subtitle/privacy for one locale. Only pass fields you want to change.
|
|
41
|
+
- `localizationId` **(required)** — an appInfoLocalization id
|
|
42
|
+
- `name` — app name (max 30 chars)
|
|
43
|
+
- `subtitle` — subtitle (max 30 chars)
|
|
44
|
+
- `privacyPolicyUrl`
|
|
45
|
+
- `privacyPolicyText`
|
|
46
|
+
- `dryRun` — if true, **write nothing**; return a diff + length/limit checks (see [Dry-run mode](#dry-run-mode))
|
|
47
|
+
|
|
48
|
+
### `create_app_info_localization`
|
|
49
|
+
Add a brand-new locale's name/subtitle/privacy.
|
|
50
|
+
- `appInfoId` **(required)**
|
|
51
|
+
- `locale` **(required)** — e.g. `fr-FR`, `de-DE`
|
|
52
|
+
- `name`, `subtitle`, `privacyPolicyUrl`, `privacyPolicyText`
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Versions
|
|
57
|
+
|
|
58
|
+
### `list_app_store_versions`
|
|
59
|
+
List versions and states for an app.
|
|
60
|
+
- `appId` **(required)**
|
|
61
|
+
- `filterState` — e.g. `PREPARE_FOR_SUBMISSION`, `READY_FOR_SALE`
|
|
62
|
+
- `filterPlatform` — `IOS`, `MAC_OS`, `TV_OS`, `VISION_OS`
|
|
63
|
+
|
|
64
|
+
### `create_app_store_version`
|
|
65
|
+
Create a new version to prepare for submission.
|
|
66
|
+
- `appId` **(required)**
|
|
67
|
+
- `versionString` **(required)** — e.g. `1.3.0`
|
|
68
|
+
- `platform` — default `IOS`
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Version localizations — description, keywords, what's-new
|
|
73
|
+
|
|
74
|
+
### `list_app_store_version_localizations`
|
|
75
|
+
List per-locale listing copy for a version.
|
|
76
|
+
- `versionId` **(required)**
|
|
77
|
+
- **Returns:** array of `{ id, locale, description, keywords, promotionalText, whatsNew, marketingUrl, supportUrl }`
|
|
78
|
+
|
|
79
|
+
### `get_app_store_version_localization`
|
|
80
|
+
Read one locale's listing copy.
|
|
81
|
+
- `localizationId` **(required)**
|
|
82
|
+
|
|
83
|
+
### `update_app_store_version_localization`
|
|
84
|
+
Update keywords/description/etc. for one locale. Only pass fields to change.
|
|
85
|
+
- `localizationId` **(required)**
|
|
86
|
+
- `keywords` — comma-separated, **max 100 chars total** (e.g. `todo,tasks,planner`)
|
|
87
|
+
- `description` — max 4000 chars
|
|
88
|
+
- `promotionalText` — max 170 chars (editable without a new version)
|
|
89
|
+
- `whatsNew` — release notes, max 4000 chars
|
|
90
|
+
- `marketingUrl`
|
|
91
|
+
- `supportUrl`
|
|
92
|
+
- `dryRun` — if true, **write nothing**; return a diff + length/limit checks (see [Dry-run mode](#dry-run-mode))
|
|
93
|
+
|
|
94
|
+
### `create_app_store_version_localization`
|
|
95
|
+
Add a new locale to a version.
|
|
96
|
+
- `versionId` **(required)**
|
|
97
|
+
- `locale` **(required)**
|
|
98
|
+
- `description`, `keywords`, `promotionalText`, `whatsNew`, `marketingUrl`, `supportUrl`
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Screenshots
|
|
103
|
+
|
|
104
|
+
Screenshots are organized into **sets**, one per device display type, attached to
|
|
105
|
+
a version localization.
|
|
106
|
+
|
|
107
|
+
Common `displayType` values: `APP_IPHONE_67`, `APP_IPHONE_65`, `APP_IPHONE_61`,
|
|
108
|
+
`APP_IPHONE_55`, `APP_IPAD_PRO_129`, `APP_IPAD_PRO_3GEN_11`.
|
|
109
|
+
|
|
110
|
+
### `list_screenshot_sets`
|
|
111
|
+
- `localizationId` **(required)** — an appStoreVersionLocalization id
|
|
112
|
+
|
|
113
|
+
### `create_screenshot_set`
|
|
114
|
+
- `localizationId` **(required)**
|
|
115
|
+
- `displayType` **(required)**
|
|
116
|
+
|
|
117
|
+
### `list_screenshots`
|
|
118
|
+
- `screenshotSetId` **(required)**
|
|
119
|
+
|
|
120
|
+
### `upload_screenshot`
|
|
121
|
+
Upload an image into a set. Handles reserve → upload bytes → commit (with checksum).
|
|
122
|
+
- `screenshotSetId` **(required)**
|
|
123
|
+
- `filePath` **(required)** — absolute path to a PNG/JPEG matching the device's exact dimensions
|
|
124
|
+
- `fileName` — optional override for the stored name
|
|
125
|
+
|
|
126
|
+
### `delete_screenshot`
|
|
127
|
+
- `screenshotId` **(required)**
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Fleet audit
|
|
132
|
+
|
|
133
|
+
### audit_apps
|
|
134
|
+
Read-only health check across **all** your apps (or a subset). For each app it
|
|
135
|
+
inspects the editable App Store version and app info, then flags listing/ASO
|
|
136
|
+
issues. Writes nothing.
|
|
137
|
+
|
|
138
|
+
- `appIds` — array of app ids to limit the audit to (default: all apps)
|
|
139
|
+
- `limit` — audit at most this many apps (default: all)
|
|
140
|
+
- `checkScreenshots` — also flag the primary locale when it has no screenshots
|
|
141
|
+
(slower; extra API calls). Default `false`.
|
|
142
|
+
- `keywordUseThreshold` — flag the keyword field as under-used below this many
|
|
143
|
+
chars (default `70` of 100).
|
|
144
|
+
|
|
145
|
+
**Issue codes** (each finding has `severity` of `error` / `warning` / `info` / `opportunity`):
|
|
146
|
+
|
|
147
|
+
| code | meaning |
|
|
148
|
+
| --- | --- |
|
|
149
|
+
| `missing_subtitle` | No subtitle (wasted free ASO keywords) |
|
|
150
|
+
| `missing_keywords` | Keyword field empty |
|
|
151
|
+
| `keywords_underused` | Keyword field uses < threshold of 100 chars |
|
|
152
|
+
| `missing_description` | No description |
|
|
153
|
+
| `missing_promotional_text` | No promotional text |
|
|
154
|
+
| `missing_whats_new` | No release notes |
|
|
155
|
+
| `single_locale` | Listing in only one locale |
|
|
156
|
+
| `missing_screenshots` | No screenshots (only when `checkScreenshots: true`) |
|
|
157
|
+
| `no_editable_version` | No version in an editable state right now |
|
|
158
|
+
| `audit_failed` | The app could not be fully read (details in the message) |
|
|
159
|
+
|
|
160
|
+
**Returns**
|
|
161
|
+
```jsonc
|
|
162
|
+
{
|
|
163
|
+
"summary": {
|
|
164
|
+
"appsAudited": 62,
|
|
165
|
+
"appsWithNoIssues": 11,
|
|
166
|
+
"appsWithIssues": 51,
|
|
167
|
+
"issuesByType": { "missing_subtitle": 8, "keywords_underused": 14, ... },
|
|
168
|
+
"screenshotsChecked": false
|
|
169
|
+
},
|
|
170
|
+
"findings": [
|
|
171
|
+
{ "appId": "…", "name": "…", "bundleId": "…", "primaryLocale": "en-US",
|
|
172
|
+
"issueCount": 6, "issues": [ { "severity": "warning", "code": "missing_keywords", "message": "…" } ] }
|
|
173
|
+
] // sorted by issueCount, worst first
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Dry-run mode
|
|
180
|
+
|
|
181
|
+
The `update_app_store_version_localization` and `update_app_info_localization`
|
|
182
|
+
tools accept `dryRun: true`. Instead of writing, the tool reads the current
|
|
183
|
+
values, computes a diff, validates lengths against Apple's limits, and returns:
|
|
184
|
+
|
|
185
|
+
```jsonc
|
|
186
|
+
{
|
|
187
|
+
"dryRun": true,
|
|
188
|
+
"id": "…",
|
|
189
|
+
"changes": [
|
|
190
|
+
{ "field": "keywords", "from": "old,kw", "to": "new,kw",
|
|
191
|
+
"changed": true, "newLength": 6, "limit": 100, "exceedsLimit": false }
|
|
192
|
+
],
|
|
193
|
+
"warnings": [ "'keywords' is 111 chars — exceeds Apple's limit of 100." ],
|
|
194
|
+
"note": "No changes were written. Re-run without dryRun to apply."
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Re-run the same call **without** `dryRun` to apply. On a real write, any
|
|
199
|
+
length warnings are attached to the response under `_warnings`.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Raw API access
|
|
204
|
+
|
|
205
|
+
### `raw_request`
|
|
206
|
+
Call any App Store Connect endpoint not covered above — previews, pricing,
|
|
207
|
+
availability, TestFlight, in-app purchases, subscriptions, customer reviews,
|
|
208
|
+
analytics, sales & finance reports, etc.
|
|
209
|
+
|
|
210
|
+
- `method` **(required)** — `GET` | `POST` | `PATCH` | `DELETE`
|
|
211
|
+
- `path` **(required)** — relative (e.g. `/apps` or `/appStoreVersions/{id}`; `/v1`
|
|
212
|
+
is prepended automatically) or a full `https://…` URL (e.g. a paging `next` link)
|
|
213
|
+
- `query` — flat object of query params
|
|
214
|
+
- `body` — JSON request body for POST/PATCH
|
|
215
|
+
|
|
216
|
+
**Examples**
|
|
217
|
+
```jsonc
|
|
218
|
+
// Read customer reviews
|
|
219
|
+
{ "method": "GET", "path": "/apps/123456/customerReviews", "query": { "limit": 50 } }
|
|
220
|
+
|
|
221
|
+
// Reply to a review
|
|
222
|
+
{ "method": "POST", "path": "/customerReviewResponses",
|
|
223
|
+
"body": { "data": { "type": "customerReviewResponses",
|
|
224
|
+
"attributes": { "responseBody": "Thanks for the feedback!" },
|
|
225
|
+
"relationships": { "review": { "data": { "type": "customerReviews", "id": "REVIEW_ID" } } } } } }
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Reference: <https://developer.apple.com/documentation/appstoreconnectapi>
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Notes on editability
|
|
233
|
+
|
|
234
|
+
- Listing copy (description, keywords, name, subtitle, what's-new) only **saves on a
|
|
235
|
+
version in an editable state** such as `PREPARE_FOR_SUBMISSION`. Promotional text
|
|
236
|
+
is the exception — it can be updated on a live version.
|
|
237
|
+
- Edits change the **draft**. Changes go public only after you submit and Apple approves.
|
|
238
|
+
- The API paginates; list tools auto-follow pages (up to a safety cap). For very
|
|
239
|
+
large accounts use `raw_request` with explicit `limit`/cursor if needed.
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "appstore-api-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for Apple App Store Connect — manage apps, keywords, descriptions, titles, screenshots, versions and the full API from any MCP client (Claude, Cursor, Cline, Windsurf, VS Code, Zed, Continue, and custom agents). Includes a fleet-wide ASO audit and dry-run previews.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"appstore-api-mcp": "src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "src/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"CHANGELOG.md",
|
|
15
|
+
"docs",
|
|
16
|
+
".env.example"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"start": "node src/index.js"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"model-context-protocol",
|
|
24
|
+
"app-store-connect",
|
|
25
|
+
"appstore",
|
|
26
|
+
"app-store",
|
|
27
|
+
"itunes-connect",
|
|
28
|
+
"aso",
|
|
29
|
+
"ios",
|
|
30
|
+
"apple",
|
|
31
|
+
"claude",
|
|
32
|
+
"keywords",
|
|
33
|
+
"screenshots",
|
|
34
|
+
"metadata"
|
|
35
|
+
],
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"author": "Sviatoslav Fil",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/fil-technology/appstore-api-mcp.git"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/fil-technology/appstore-api-mcp#readme",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/fil-technology/appstore-api-mcp/issues"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
48
|
+
"jose": "^5.9.6"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=18"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { importPKCS8, SignJWT } from "jose";
|
|
4
|
+
|
|
5
|
+
const BASE_URL = "https://api.appstoreconnect.apple.com";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Thin client for the App Store Connect API.
|
|
9
|
+
* Handles ES256 JWT minting (cached until shortly before expiry) and
|
|
10
|
+
* exposes request helpers plus a multi-step screenshot uploader.
|
|
11
|
+
*/
|
|
12
|
+
export class AppStoreConnectClient {
|
|
13
|
+
constructor({ keyId, issuerId, privateKeyPath, privateKey, privateKeyBase64 }) {
|
|
14
|
+
if (!keyId) throw new Error("ASC_KEY_ID is required");
|
|
15
|
+
if (!issuerId) throw new Error("ASC_ISSUER_ID is required");
|
|
16
|
+
this.keyId = keyId;
|
|
17
|
+
this.issuerId = issuerId;
|
|
18
|
+
// Accept the .p8 key three ways, in priority order:
|
|
19
|
+
// 1. inline PEM text (ASC_PRIVATE_KEY)
|
|
20
|
+
// 2. base64-encoded PEM (ASC_PRIVATE_KEY_BASE64) — easiest for env vars
|
|
21
|
+
// 3. path to the .p8 file (ASC_PRIVATE_KEY_PATH)
|
|
22
|
+
this._pem =
|
|
23
|
+
privateKey ||
|
|
24
|
+
(privateKeyBase64
|
|
25
|
+
? Buffer.from(privateKeyBase64, "base64").toString("utf8")
|
|
26
|
+
: null) ||
|
|
27
|
+
(privateKeyPath ? readFileSync(privateKeyPath, "utf8") : null);
|
|
28
|
+
if (!this._pem)
|
|
29
|
+
throw new Error(
|
|
30
|
+
"Provide the API key via ASC_PRIVATE_KEY_PATH, ASC_PRIVATE_KEY, or ASC_PRIVATE_KEY_BASE64",
|
|
31
|
+
);
|
|
32
|
+
this._token = null;
|
|
33
|
+
this._tokenExp = 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async _getToken() {
|
|
37
|
+
const now = Math.floor(Date.now() / 1000);
|
|
38
|
+
// Reuse token while it still has >60s of life.
|
|
39
|
+
if (this._token && now < this._tokenExp - 60) return this._token;
|
|
40
|
+
const key = await importPKCS8(this._pem, "ES256");
|
|
41
|
+
const exp = now + 19 * 60; // App Store Connect caps token life at 20 min.
|
|
42
|
+
this._token = await new SignJWT({})
|
|
43
|
+
.setProtectedHeader({ alg: "ES256", kid: this.keyId, typ: "JWT" })
|
|
44
|
+
.setIssuedAt(now)
|
|
45
|
+
.setIssuer(this.issuerId)
|
|
46
|
+
.setExpirationTime(exp)
|
|
47
|
+
.setAudience("appstoreconnect-v1")
|
|
48
|
+
.sign(key);
|
|
49
|
+
this._tokenExp = exp;
|
|
50
|
+
return this._token;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Core request. `path` may be a full URL (e.g. a paging `next` link) or a
|
|
55
|
+
* path relative to the API root, with or without the leading /v1.
|
|
56
|
+
*/
|
|
57
|
+
async request(method, path, { query, body } = {}) {
|
|
58
|
+
const token = await this._getToken();
|
|
59
|
+
let url;
|
|
60
|
+
if (/^https?:\/\//.test(path)) {
|
|
61
|
+
url = new URL(path);
|
|
62
|
+
} else {
|
|
63
|
+
const clean = path.startsWith("/") ? path : `/${path}`;
|
|
64
|
+
const withVersion = clean.startsWith("/v1") || clean.startsWith("/v2")
|
|
65
|
+
? clean
|
|
66
|
+
: `/v1${clean}`;
|
|
67
|
+
url = new URL(BASE_URL + withVersion);
|
|
68
|
+
}
|
|
69
|
+
if (query) {
|
|
70
|
+
for (const [k, v] of Object.entries(query)) {
|
|
71
|
+
if (v === undefined || v === null) continue;
|
|
72
|
+
url.searchParams.set(k, Array.isArray(v) ? v.join(",") : String(v));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
76
|
+
let payload;
|
|
77
|
+
if (body !== undefined) {
|
|
78
|
+
headers["Content-Type"] = "application/json";
|
|
79
|
+
payload = JSON.stringify(body);
|
|
80
|
+
}
|
|
81
|
+
const res = await fetch(url, { method, headers, body: payload });
|
|
82
|
+
const text = await res.text();
|
|
83
|
+
let data = null;
|
|
84
|
+
if (text) {
|
|
85
|
+
try {
|
|
86
|
+
data = JSON.parse(text);
|
|
87
|
+
} catch {
|
|
88
|
+
data = text;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
const detail =
|
|
93
|
+
data && data.errors
|
|
94
|
+
? data.errors
|
|
95
|
+
.map((e) => `${e.status} ${e.code}: ${e.title} — ${e.detail}`)
|
|
96
|
+
.join("; ")
|
|
97
|
+
: typeof data === "string"
|
|
98
|
+
? data
|
|
99
|
+
: JSON.stringify(data);
|
|
100
|
+
const err = new Error(
|
|
101
|
+
`App Store Connect ${method} ${url.pathname} failed: ${res.status} ${res.statusText} — ${detail}`,
|
|
102
|
+
);
|
|
103
|
+
err.status = res.status;
|
|
104
|
+
err.body = data;
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
return data;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get(path, query) {
|
|
111
|
+
return this.request("GET", path, { query });
|
|
112
|
+
}
|
|
113
|
+
post(path, body) {
|
|
114
|
+
return this.request("POST", path, { body });
|
|
115
|
+
}
|
|
116
|
+
patch(path, body) {
|
|
117
|
+
return this.request("PATCH", path, { body });
|
|
118
|
+
}
|
|
119
|
+
delete(path) {
|
|
120
|
+
return this.request("DELETE", path);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Follow `links.next` and concatenate `data` arrays up to `maxPages`. */
|
|
124
|
+
async getAll(path, query, maxPages = 20) {
|
|
125
|
+
let page = await this.get(path, query);
|
|
126
|
+
const all = Array.isArray(page.data) ? [...page.data] : [];
|
|
127
|
+
let pages = 1;
|
|
128
|
+
while (page.links && page.links.next && pages < maxPages) {
|
|
129
|
+
page = await this.request("GET", page.links.next);
|
|
130
|
+
if (Array.isArray(page.data)) all.push(...page.data);
|
|
131
|
+
pages++;
|
|
132
|
+
}
|
|
133
|
+
return all;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Upload an image to a freshly-reserved upload-asset (screenshot or preview).
|
|
138
|
+
* `uploadOperations` come from the reservation's attributes.
|
|
139
|
+
*/
|
|
140
|
+
async uploadAsset(uploadOperations, fileBuffer) {
|
|
141
|
+
for (const op of uploadOperations) {
|
|
142
|
+
const chunk = fileBuffer.subarray(op.offset, op.offset + op.length);
|
|
143
|
+
const headers = {};
|
|
144
|
+
for (const h of op.requestHeaders || []) headers[h.name] = h.value;
|
|
145
|
+
const res = await fetch(op.url, {
|
|
146
|
+
method: op.method,
|
|
147
|
+
headers,
|
|
148
|
+
body: chunk,
|
|
149
|
+
});
|
|
150
|
+
if (!res.ok) {
|
|
151
|
+
const t = await res.text();
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Asset chunk upload failed (${op.method} ${op.url}): ${res.status} ${t}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
static md5(buffer) {
|
|
160
|
+
return createHash("md5").update(buffer).digest("hex");
|
|
161
|
+
}
|
|
162
|
+
}
|