ios-app-review-plugin 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.
Files changed (205) hide show
  1. package/.claude/settings.local.json +42 -0
  2. package/.github/actions/ios-review/action.yml +106 -0
  3. package/.github/workflows/ci.yml +103 -0
  4. package/.github/workflows/publish.yml +57 -0
  5. package/CHANGELOG.md +66 -0
  6. package/CONTRIBUTING.md +175 -0
  7. package/LICENSE +21 -0
  8. package/README.md +205 -0
  9. package/bitrise/step.sh +128 -0
  10. package/bitrise/step.yml +101 -0
  11. package/dist/analyzer.d.ts.map +1 -0
  12. package/dist/analyzers/asc-iap.d.ts.map +1 -0
  13. package/dist/analyzers/asc-metadata.d.ts.map +1 -0
  14. package/dist/analyzers/asc-screenshots.d.ts.map +1 -0
  15. package/dist/analyzers/asc-version.d.ts.map +1 -0
  16. package/dist/analyzers/code-scanner.d.ts.map +1 -0
  17. package/dist/analyzers/deprecated-api.d.ts.map +1 -0
  18. package/dist/analyzers/entitlements.d.ts.map +1 -0
  19. package/dist/analyzers/index.d.ts.map +1 -0
  20. package/dist/analyzers/info-plist.d.ts.map +1 -0
  21. package/dist/analyzers/privacy.d.ts.map +1 -0
  22. package/dist/analyzers/private-api.d.ts.map +1 -0
  23. package/dist/analyzers/security.d.ts.map +1 -0
  24. package/dist/analyzers/ui-ux.d.ts.map +1 -0
  25. package/dist/asc/auth.d.ts.map +1 -0
  26. package/dist/asc/client.d.ts.map +1 -0
  27. package/dist/asc/endpoints/apps.d.ts.map +1 -0
  28. package/dist/asc/endpoints/iap.d.ts.map +1 -0
  29. package/dist/asc/endpoints/screenshots.d.ts.map +1 -0
  30. package/dist/asc/endpoints/versions.d.ts.map +1 -0
  31. package/dist/asc/errors.d.ts.map +1 -0
  32. package/dist/asc/index.d.ts.map +1 -0
  33. package/dist/asc/types.d.ts.map +1 -0
  34. package/dist/badge/generator.d.ts.map +1 -0
  35. package/dist/badge/index.d.ts.map +1 -0
  36. package/dist/badge/types.d.ts.map +1 -0
  37. package/dist/cache/file-cache.d.ts.map +1 -0
  38. package/dist/cache/index.d.ts.map +1 -0
  39. package/dist/cache/types.d.ts.map +1 -0
  40. package/dist/cli/commands/help.d.ts.map +1 -0
  41. package/dist/cli/commands/scan.d.ts.map +1 -0
  42. package/dist/cli/commands/version.d.ts.map +1 -0
  43. package/dist/cli/index.d.ts.map +1 -0
  44. package/dist/cli/types.d.ts.map +1 -0
  45. package/dist/git/diff.d.ts.map +1 -0
  46. package/dist/git/index.d.ts.map +1 -0
  47. package/dist/git/types.d.ts.map +1 -0
  48. package/dist/guidelines/database.d.ts.map +1 -0
  49. package/dist/guidelines/index.d.ts.map +1 -0
  50. package/dist/guidelines/matcher.d.ts.map +1 -0
  51. package/dist/guidelines/types.d.ts.map +1 -0
  52. package/dist/history/comparator.d.ts.map +1 -0
  53. package/dist/history/index.d.ts.map +1 -0
  54. package/dist/history/store.d.ts.map +1 -0
  55. package/dist/history/types.d.ts.map +1 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +994 -0
  58. package/dist/parsers/index.d.ts.map +1 -0
  59. package/dist/parsers/plist.d.ts.map +1 -0
  60. package/dist/parsers/xcodeproj.d.ts.map +1 -0
  61. package/dist/progress/index.d.ts.map +1 -0
  62. package/dist/progress/reporter.d.ts.map +1 -0
  63. package/dist/progress/types.d.ts.map +1 -0
  64. package/dist/reports/html.d.ts.map +1 -0
  65. package/dist/reports/index.d.ts.map +1 -0
  66. package/dist/reports/json.d.ts.map +1 -0
  67. package/dist/reports/markdown.d.ts.map +1 -0
  68. package/dist/reports/types.d.ts.map +1 -0
  69. package/dist/rules/engine.d.ts.map +1 -0
  70. package/dist/rules/index.d.ts.map +1 -0
  71. package/dist/rules/loader.d.ts.map +1 -0
  72. package/dist/rules/types.d.ts.map +1 -0
  73. package/dist/types/index.d.ts.map +1 -0
  74. package/docs/ANALYZERS.md +237 -0
  75. package/docs/API.md +308 -0
  76. package/docs/BADGES.md +130 -0
  77. package/docs/CI_CD.md +283 -0
  78. package/docs/CLI.md +140 -0
  79. package/docs/REPORTS.md +212 -0
  80. package/docs/ROADMAP.md +267 -0
  81. package/docs/RULES.md +182 -0
  82. package/docs/SECURITY.md +89 -0
  83. package/docs/TROUBLESHOOTING.md +227 -0
  84. package/docs/tutorials/ASC_SETUP.md +188 -0
  85. package/docs/tutorials/CI_INTEGRATION.md +292 -0
  86. package/docs/tutorials/CUSTOM_RULES.md +291 -0
  87. package/docs/tutorials/GETTING_STARTED.md +226 -0
  88. package/docs/video-scripts/01-introduction.md +106 -0
  89. package/docs/video-scripts/02-cli-usage.md +120 -0
  90. package/docs/video-scripts/03-ci-integration.md +198 -0
  91. package/eslint.config.js +33 -0
  92. package/examples/.ios-review-rules.json +82 -0
  93. package/examples/bitrise-workflow.yml +129 -0
  94. package/examples/fastlane-lane.rb +71 -0
  95. package/examples/github-action.yml +147 -0
  96. package/fastlane/Fastfile.example +114 -0
  97. package/fastlane/README.md +99 -0
  98. package/jest.config.js +36 -0
  99. package/package.json +65 -0
  100. package/scripts/benchmark.ts +112 -0
  101. package/scripts/debug-parser.ts +37 -0
  102. package/scripts/debug-pbxproj.ts +36 -0
  103. package/scripts/debug-specific.ts +47 -0
  104. package/scripts/test-analyze.ts +67 -0
  105. package/scripts/xcode-cloud-review.sh +167 -0
  106. package/src/analyzer.ts +227 -0
  107. package/src/analyzers/asc-iap.ts +300 -0
  108. package/src/analyzers/asc-metadata.ts +326 -0
  109. package/src/analyzers/asc-screenshots.ts +310 -0
  110. package/src/analyzers/asc-version.ts +368 -0
  111. package/src/analyzers/code-scanner.ts +408 -0
  112. package/src/analyzers/deprecated-api.ts +390 -0
  113. package/src/analyzers/entitlements.ts +345 -0
  114. package/src/analyzers/index.ts +12 -0
  115. package/src/analyzers/info-plist.ts +409 -0
  116. package/src/analyzers/privacy.ts +376 -0
  117. package/src/analyzers/private-api.ts +377 -0
  118. package/src/analyzers/security.ts +327 -0
  119. package/src/analyzers/ui-ux.ts +509 -0
  120. package/src/asc/auth.ts +204 -0
  121. package/src/asc/client.ts +258 -0
  122. package/src/asc/endpoints/apps.ts +115 -0
  123. package/src/asc/endpoints/iap.ts +171 -0
  124. package/src/asc/endpoints/screenshots.ts +164 -0
  125. package/src/asc/endpoints/versions.ts +174 -0
  126. package/src/asc/errors.ts +109 -0
  127. package/src/asc/index.ts +108 -0
  128. package/src/asc/types.ts +369 -0
  129. package/src/badge/generator.ts +48 -0
  130. package/src/badge/index.ts +2 -0
  131. package/src/badge/types.ts +5 -0
  132. package/src/cache/file-cache.ts +75 -0
  133. package/src/cache/index.ts +2 -0
  134. package/src/cache/types.ts +10 -0
  135. package/src/cli/commands/help.ts +41 -0
  136. package/src/cli/commands/scan.ts +44 -0
  137. package/src/cli/commands/version.ts +12 -0
  138. package/src/cli/index.ts +92 -0
  139. package/src/cli/types.ts +17 -0
  140. package/src/git/diff.ts +21 -0
  141. package/src/git/index.ts +2 -0
  142. package/src/git/types.ts +5 -0
  143. package/src/guidelines/database.ts +344 -0
  144. package/src/guidelines/index.ts +4 -0
  145. package/src/guidelines/matcher.ts +84 -0
  146. package/src/guidelines/types.ts +28 -0
  147. package/src/history/comparator.ts +114 -0
  148. package/src/history/index.ts +3 -0
  149. package/src/history/store.ts +135 -0
  150. package/src/history/types.ts +40 -0
  151. package/src/index.ts +1113 -0
  152. package/src/parsers/index.ts +3 -0
  153. package/src/parsers/plist.ts +253 -0
  154. package/src/parsers/xcodeproj.ts +265 -0
  155. package/src/progress/index.ts +2 -0
  156. package/src/progress/reporter.ts +65 -0
  157. package/src/progress/types.ts +9 -0
  158. package/src/reports/html.ts +322 -0
  159. package/src/reports/index.ts +20 -0
  160. package/src/reports/json.ts +92 -0
  161. package/src/reports/markdown.ts +187 -0
  162. package/src/reports/types.ts +26 -0
  163. package/src/rules/engine.ts +121 -0
  164. package/src/rules/index.ts +3 -0
  165. package/src/rules/loader.ts +83 -0
  166. package/src/rules/types.ts +25 -0
  167. package/src/types/index.ts +247 -0
  168. package/tests/analyzer.test.ts +142 -0
  169. package/tests/analyzers/asc-iap.test.ts +228 -0
  170. package/tests/analyzers/asc-metadata.test.ts +210 -0
  171. package/tests/analyzers/asc-screenshots.test.ts +135 -0
  172. package/tests/analyzers/asc-version.test.ts +259 -0
  173. package/tests/analyzers/code-scanner.test.ts +745 -0
  174. package/tests/analyzers/deprecated-api.test.ts +286 -0
  175. package/tests/analyzers/entitlements.test.ts +411 -0
  176. package/tests/analyzers/info-plist.test.ts +148 -0
  177. package/tests/analyzers/privacy.test.ts +623 -0
  178. package/tests/analyzers/private-api.test.ts +255 -0
  179. package/tests/analyzers/security.test.ts +300 -0
  180. package/tests/analyzers/ui-ux.test.ts +357 -0
  181. package/tests/asc/auth.test.ts +189 -0
  182. package/tests/asc/client.test.ts +207 -0
  183. package/tests/asc/endpoints.test.ts +1359 -0
  184. package/tests/badge/generator.test.ts +73 -0
  185. package/tests/cache/file-cache.test.ts +124 -0
  186. package/tests/cli/cli-index.test.ts +510 -0
  187. package/tests/cli/commands.test.ts +67 -0
  188. package/tests/cli/scan.test.ts +152 -0
  189. package/tests/git/diff.test.ts +69 -0
  190. package/tests/guidelines/matcher.test.ts +209 -0
  191. package/tests/history/comparator.test.ts +272 -0
  192. package/tests/history/store.test.ts +200 -0
  193. package/tests/integration/cli.test.ts +95 -0
  194. package/tests/integration/e2e.test.ts +130 -0
  195. package/tests/parsers/plist.test.ts +240 -0
  196. package/tests/parsers/xcodeproj.test.ts +289 -0
  197. package/tests/progress/reporter.test.ts +117 -0
  198. package/tests/reports/html.test.ts +176 -0
  199. package/tests/reports/json.test.ts +235 -0
  200. package/tests/reports/markdown.test.ts +196 -0
  201. package/tests/rules/engine.test.ts +229 -0
  202. package/tests/rules/loader.test.ts +187 -0
  203. package/tests/setup.ts +15 -0
  204. package/tsconfig.json +27 -0
  205. package/tsconfig.test.json +9 -0
@@ -0,0 +1,258 @@
1
+ /**
2
+ * App Store Connect API Client
3
+ *
4
+ * Handles HTTP requests with rate limiting, retries, and pagination.
5
+ */
6
+
7
+ import { getToken } from './auth.js';
8
+ import {
9
+ ASCAPIError,
10
+ ASCAuthError,
11
+ ASCRateLimitError,
12
+ } from './errors.js';
13
+ import type {
14
+ ASCListResponse,
15
+ ASCErrorResponse,
16
+ ASCResource,
17
+ } from './types.js';
18
+
19
+ const BASE_URL = 'https://api.appstoreconnect.apple.com/v1';
20
+
21
+ /**
22
+ * Rate limiter state
23
+ */
24
+ interface RateLimiterState {
25
+ tokens: number;
26
+ lastRefill: number;
27
+ retryAfter?: number;
28
+ }
29
+
30
+ const RATE_LIMIT_TOKENS = 500; // 500 requests per hour
31
+ const RATE_LIMIT_REFILL_INTERVAL = 60 * 60 * 1000; // 1 hour in ms
32
+
33
+ let rateLimiter: RateLimiterState = {
34
+ tokens: RATE_LIMIT_TOKENS,
35
+ lastRefill: Date.now(),
36
+ };
37
+
38
+ /**
39
+ * Retry configuration
40
+ */
41
+ const MAX_RETRIES = 3;
42
+ const INITIAL_RETRY_DELAY = 1000; // 1 second
43
+
44
+ /**
45
+ * Check and consume a rate limit token
46
+ */
47
+ function consumeRateLimitToken(): boolean {
48
+ const now = Date.now();
49
+ const timeSinceRefill = now - rateLimiter.lastRefill;
50
+
51
+ // Refill tokens if an hour has passed
52
+ if (timeSinceRefill >= RATE_LIMIT_REFILL_INTERVAL) {
53
+ rateLimiter.tokens = RATE_LIMIT_TOKENS;
54
+ rateLimiter.lastRefill = now;
55
+ }
56
+
57
+ // Check if we need to wait for retry-after
58
+ if (rateLimiter.retryAfter && now < rateLimiter.retryAfter) {
59
+ return false;
60
+ }
61
+
62
+ if (rateLimiter.tokens > 0) {
63
+ rateLimiter.tokens--;
64
+ return true;
65
+ }
66
+
67
+ return false;
68
+ }
69
+
70
+ /**
71
+ * Set retry-after from response header
72
+ */
73
+ function setRetryAfter(seconds: number): void {
74
+ rateLimiter.retryAfter = Date.now() + seconds * 1000;
75
+ }
76
+
77
+ /**
78
+ * Sleep for a given number of milliseconds
79
+ */
80
+ function sleep(ms: number): Promise<void> {
81
+ return new Promise((resolve) => setTimeout(resolve, ms));
82
+ }
83
+
84
+ /**
85
+ * Parse error response
86
+ */
87
+ async function parseErrorResponse(response: Response): Promise<ASCErrorResponse | null> {
88
+ try {
89
+ const text = await response.text();
90
+ if (text) {
91
+ return JSON.parse(text) as ASCErrorResponse;
92
+ }
93
+ } catch {
94
+ // Ignore parse errors
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Make an authenticated request to the ASC API
101
+ */
102
+ export async function request<T>(
103
+ path: string,
104
+ options: RequestInit = {}
105
+ ): Promise<T> {
106
+ let lastError: Error | null = null;
107
+
108
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
109
+ // Check rate limit
110
+ if (!consumeRateLimitToken()) {
111
+ const waitTime = rateLimiter.retryAfter
112
+ ? rateLimiter.retryAfter - Date.now()
113
+ : RATE_LIMIT_REFILL_INTERVAL - (Date.now() - rateLimiter.lastRefill);
114
+
115
+ throw new ASCRateLimitError(Math.ceil(waitTime / 1000));
116
+ }
117
+
118
+ try {
119
+ const token = await getToken();
120
+
121
+ const url = path.startsWith('http') ? path : `${BASE_URL}${path}`;
122
+
123
+ const response = await fetch(url, {
124
+ ...options,
125
+ headers: {
126
+ Authorization: `Bearer ${token}`,
127
+ 'Content-Type': 'application/json',
128
+ ...options.headers,
129
+ },
130
+ });
131
+
132
+ // Handle rate limiting
133
+ if (response.status === 429) {
134
+ const retryAfter = response.headers.get('Retry-After');
135
+ const seconds = retryAfter ? parseInt(retryAfter, 10) : 60;
136
+ setRetryAfter(seconds);
137
+
138
+ if (attempt < MAX_RETRIES) {
139
+ await sleep(seconds * 1000);
140
+ continue;
141
+ }
142
+
143
+ throw new ASCRateLimitError(seconds);
144
+ }
145
+
146
+ // Handle auth errors
147
+ if (response.status === 401 || response.status === 403) {
148
+ const errorResponse = await parseErrorResponse(response);
149
+ throw new ASCAuthError(
150
+ errorResponse?.errors?.[0]?.detail ?? 'Authentication failed'
151
+ );
152
+ }
153
+
154
+ // Handle server errors with retry
155
+ if (response.status >= 500 && attempt < MAX_RETRIES) {
156
+ lastError = new ASCAPIError(`Server error: ${response.status}`, response.status);
157
+ await sleep(INITIAL_RETRY_DELAY * Math.pow(2, attempt));
158
+ continue;
159
+ }
160
+
161
+ // Handle other errors
162
+ if (!response.ok) {
163
+ const errorResponse = await parseErrorResponse(response);
164
+ throw ASCAPIError.fromResponse(response.status, errorResponse?.errors);
165
+ }
166
+
167
+ // Parse successful response
168
+ const data = await response.json();
169
+ return data as T;
170
+ } catch (error) {
171
+ if (error instanceof ASCAPIError || error instanceof ASCAuthError || error instanceof ASCRateLimitError) {
172
+ throw error;
173
+ }
174
+
175
+ lastError = error instanceof Error ? error : new Error(String(error));
176
+
177
+ // Retry on network errors
178
+ if (attempt < MAX_RETRIES) {
179
+ await sleep(INITIAL_RETRY_DELAY * Math.pow(2, attempt));
180
+ continue;
181
+ }
182
+ }
183
+ }
184
+
185
+ throw lastError ?? new ASCAPIError('Request failed after retries', 0);
186
+ }
187
+
188
+ /**
189
+ * Make a GET request
190
+ */
191
+ export async function get<T>(
192
+ path: string,
193
+ params?: Record<string, string | number | boolean | undefined>
194
+ ): Promise<T> {
195
+ let url = path;
196
+
197
+ if (params) {
198
+ const searchParams = new URLSearchParams();
199
+ for (const [key, value] of Object.entries(params)) {
200
+ if (value !== undefined) {
201
+ searchParams.append(key, String(value));
202
+ }
203
+ }
204
+ const queryString = searchParams.toString();
205
+ if (queryString) {
206
+ url += (url.includes('?') ? '&' : '?') + queryString;
207
+ }
208
+ }
209
+
210
+ return request<T>(url, { method: 'GET' });
211
+ }
212
+
213
+ /**
214
+ * Fetch all pages of a paginated endpoint
215
+ */
216
+ export async function getAllPages<T extends ASCResource>(
217
+ path: string,
218
+ params?: Record<string, string | number | boolean | undefined>,
219
+ maxPages = 10
220
+ ): Promise<T[]> {
221
+ const allData: T[] = [];
222
+ let nextUrl: string | undefined = path;
223
+ let pageCount = 0;
224
+
225
+ // Build initial URL with params
226
+ if (params) {
227
+ const searchParams = new URLSearchParams();
228
+ for (const [key, value] of Object.entries(params)) {
229
+ if (value !== undefined) {
230
+ searchParams.append(key, String(value));
231
+ }
232
+ }
233
+ const queryString = searchParams.toString();
234
+ if (queryString) {
235
+ nextUrl += (nextUrl.includes('?') ? '&' : '?') + queryString;
236
+ }
237
+ }
238
+
239
+ while (nextUrl && pageCount < maxPages) {
240
+ const pageResponse: ASCListResponse<T> = await get<ASCListResponse<T>>(nextUrl);
241
+ allData.push(...pageResponse.data);
242
+
243
+ nextUrl = pageResponse.links?.next;
244
+ pageCount++;
245
+ }
246
+
247
+ return allData;
248
+ }
249
+
250
+ /**
251
+ * Reset rate limiter (useful for testing)
252
+ */
253
+ export function resetRateLimiter(): void {
254
+ rateLimiter = {
255
+ tokens: RATE_LIMIT_TOKENS,
256
+ lastRefill: Date.now(),
257
+ };
258
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * App Store Connect App Endpoints
3
+ */
4
+
5
+ import { get, getAllPages } from '../client.js';
6
+ import { ASCAppNotFoundError } from '../errors.js';
7
+ import type {
8
+ ASCResponse,
9
+ ASCListResponse,
10
+ App,
11
+ AppInfo,
12
+ AppInfoLocalization,
13
+ } from '../types.js';
14
+
15
+ /**
16
+ * Get an app by its bundle ID
17
+ */
18
+ export async function getAppByBundleId(bundleId: string): Promise<App> {
19
+ const response = await get<ASCListResponse<App>>('/apps', {
20
+ 'filter[bundleId]': bundleId,
21
+ 'fields[apps]': 'name,bundleId,sku,primaryLocale,contentRightsDeclaration,isOrEverWasMadeForKids,availableInNewTerritories',
22
+ limit: 1,
23
+ });
24
+
25
+ const app = response.data[0];
26
+ if (!app) {
27
+ throw new ASCAppNotFoundError(bundleId);
28
+ }
29
+
30
+ return app;
31
+ }
32
+
33
+ /**
34
+ * Get an app by its ASC ID
35
+ */
36
+ export async function getAppById(appId: string): Promise<App> {
37
+ const response = await get<ASCResponse<App>>(`/apps/${appId}`, {
38
+ 'fields[apps]': 'name,bundleId,sku,primaryLocale,contentRightsDeclaration,isOrEverWasMadeForKids,availableInNewTerritories',
39
+ });
40
+
41
+ return response.data;
42
+ }
43
+
44
+ /**
45
+ * Get app info records for an app
46
+ */
47
+ export async function getAppInfos(appId: string): Promise<AppInfo[]> {
48
+ return getAllPages<AppInfo>(`/apps/${appId}/appInfos`, {
49
+ 'fields[appInfos]': 'appStoreState,appStoreAgeRating,brazilAgeRating,kidsAgeBand',
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Get the current app info (non-released/editable version)
55
+ */
56
+ export async function getCurrentAppInfo(appId: string): Promise<AppInfo | undefined> {
57
+ const appInfos = await getAppInfos(appId);
58
+
59
+ // Find the app info that is not yet released (editable)
60
+ // States that indicate editable: PREPARE_FOR_SUBMISSION, READY_FOR_REVIEW, WAITING_FOR_REVIEW, IN_REVIEW
61
+ const editableStates = [
62
+ 'PREPARE_FOR_SUBMISSION',
63
+ 'READY_FOR_REVIEW',
64
+ 'WAITING_FOR_REVIEW',
65
+ 'IN_REVIEW',
66
+ 'PENDING_DEVELOPER_RELEASE',
67
+ ];
68
+
69
+ const currentInfo = appInfos.find((info) =>
70
+ editableStates.includes(info.attributes.appStoreState)
71
+ );
72
+
73
+ return currentInfo ?? appInfos[0];
74
+ }
75
+
76
+ /**
77
+ * Get app info localizations for an app info record
78
+ */
79
+ export async function getAppInfoLocalizations(appInfoId: string): Promise<AppInfoLocalization[]> {
80
+ return getAllPages<AppInfoLocalization>(`/appInfos/${appInfoId}/appInfoLocalizations`, {
81
+ 'fields[appInfoLocalizations]': 'locale,name,subtitle,privacyPolicyUrl,privacyChoicesUrl,privacyPolicyText',
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Get a specific app info localization by locale
87
+ */
88
+ export async function getAppInfoLocalization(
89
+ appInfoId: string,
90
+ locale: string
91
+ ): Promise<AppInfoLocalization | undefined> {
92
+ const localizations = await getAppInfoLocalizations(appInfoId);
93
+ return localizations.find((loc) => loc.attributes.locale === locale);
94
+ }
95
+
96
+ /**
97
+ * Get app with all related info in a single structure
98
+ */
99
+ export interface AppWithInfo {
100
+ app: App;
101
+ appInfo: AppInfo | undefined;
102
+ localizations: AppInfoLocalization[];
103
+ }
104
+
105
+ export async function getAppWithInfo(bundleId: string): Promise<AppWithInfo> {
106
+ const app = await getAppByBundleId(bundleId);
107
+ const appInfo = await getCurrentAppInfo(app.id);
108
+
109
+ let localizations: AppInfoLocalization[] = [];
110
+ if (appInfo) {
111
+ localizations = await getAppInfoLocalizations(appInfo.id);
112
+ }
113
+
114
+ return { app, appInfo, localizations };
115
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * App Store Connect In-App Purchase Endpoints
3
+ */
4
+
5
+ import { get, getAllPages } from '../client.js';
6
+ import type {
7
+ ASCResponse,
8
+ InAppPurchase,
9
+ InAppPurchaseLocalization,
10
+ InAppPurchaseAppStoreReviewScreenshot,
11
+ IAPState,
12
+ } from '../types.js';
13
+
14
+ /**
15
+ * Get all in-app purchases for an app
16
+ */
17
+ export async function getInAppPurchases(appId: string): Promise<InAppPurchase[]> {
18
+ return getAllPages<InAppPurchase>(`/apps/${appId}/inAppPurchasesV2`, {
19
+ 'fields[inAppPurchases]': 'name,productId,inAppPurchaseType,state,reviewNote,familySharable,contentHosting',
20
+ });
21
+ }
22
+
23
+ /**
24
+ * Get a specific in-app purchase by ID
25
+ */
26
+ export async function getInAppPurchaseById(iapId: string): Promise<InAppPurchase> {
27
+ const response = await get<ASCResponse<InAppPurchase>>(`/inAppPurchasesV2/${iapId}`, {
28
+ 'fields[inAppPurchases]': 'name,productId,inAppPurchaseType,state,reviewNote,familySharable,contentHosting',
29
+ });
30
+
31
+ return response.data;
32
+ }
33
+
34
+ /**
35
+ * Get localizations for an in-app purchase
36
+ */
37
+ export async function getIAPLocalizations(iapId: string): Promise<InAppPurchaseLocalization[]> {
38
+ return getAllPages<InAppPurchaseLocalization>(
39
+ `/inAppPurchasesV2/${iapId}/inAppPurchaseLocalizations`,
40
+ {
41
+ 'fields[inAppPurchaseLocalizations]': 'locale,name,description',
42
+ }
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Get review screenshot for an IAP
48
+ */
49
+ export async function getIAPReviewScreenshot(
50
+ iapId: string
51
+ ): Promise<InAppPurchaseAppStoreReviewScreenshot | undefined> {
52
+ try {
53
+ const response = await get<ASCResponse<InAppPurchaseAppStoreReviewScreenshot>>(
54
+ `/inAppPurchasesV2/${iapId}/appStoreReviewScreenshot`,
55
+ {
56
+ 'fields[inAppPurchaseAppStoreReviewScreenshots]': 'fileSize,fileName,assetDeliveryState,imageAsset',
57
+ }
58
+ );
59
+ return response.data;
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * IAP validation result
67
+ */
68
+ export interface IAPValidation {
69
+ iap: InAppPurchase;
70
+ localizations: InAppPurchaseLocalization[];
71
+ reviewScreenshot?: InAppPurchaseAppStoreReviewScreenshot | undefined;
72
+ issues: string[];
73
+ isReadyForSubmission: boolean;
74
+ }
75
+
76
+ /**
77
+ * States that indicate IAP is ready or nearly ready for submission
78
+ */
79
+ const READY_STATES: IAPState[] = ['READY_TO_SUBMIT', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'APPROVED'];
80
+
81
+ /**
82
+ * States that require action
83
+ */
84
+ const ACTION_REQUIRED_STATES: IAPState[] = ['MISSING_METADATA', 'DEVELOPER_ACTION_NEEDED', 'REJECTED'];
85
+
86
+ /**
87
+ * Validate an in-app purchase
88
+ */
89
+ export async function validateIAP(iapId: string): Promise<IAPValidation> {
90
+ const [iap, localizations, reviewScreenshot] = await Promise.all([
91
+ getInAppPurchaseById(iapId),
92
+ getIAPLocalizations(iapId),
93
+ getIAPReviewScreenshot(iapId),
94
+ ]);
95
+
96
+ const issues: string[] = [];
97
+
98
+ // Check state
99
+ if (ACTION_REQUIRED_STATES.includes(iap.attributes.state)) {
100
+ issues.push(`IAP state requires action: ${iap.attributes.state}`);
101
+ }
102
+
103
+ // Check localizations
104
+ if (localizations.length === 0) {
105
+ issues.push('No localizations configured');
106
+ } else {
107
+ // Check for missing names/descriptions
108
+ for (const loc of localizations) {
109
+ if (!loc.attributes.name) {
110
+ issues.push(`Missing name for locale: ${loc.attributes.locale}`);
111
+ }
112
+ if (!loc.attributes.description) {
113
+ issues.push(`Missing description for locale: ${loc.attributes.locale}`);
114
+ }
115
+ }
116
+ }
117
+
118
+ // Check review screenshot (required for consumable/non-consumable)
119
+ const requiresScreenshot = ['CONSUMABLE', 'NON_CONSUMABLE'].includes(iap.attributes.inAppPurchaseType);
120
+ if (requiresScreenshot && !reviewScreenshot) {
121
+ issues.push('Review screenshot required but not uploaded');
122
+ }
123
+
124
+ // Check screenshot processing state
125
+ if (reviewScreenshot?.attributes.assetDeliveryState?.state === 'FAILED') {
126
+ issues.push('Review screenshot failed to process');
127
+ }
128
+
129
+ return {
130
+ iap,
131
+ localizations,
132
+ reviewScreenshot,
133
+ issues,
134
+ isReadyForSubmission: issues.length === 0 && READY_STATES.includes(iap.attributes.state),
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Validate all IAPs for an app
140
+ */
141
+ export async function validateAllIAPs(appId: string): Promise<IAPValidation[]> {
142
+ const iaps = await getInAppPurchases(appId);
143
+ const validations: IAPValidation[] = [];
144
+
145
+ for (const iap of iaps) {
146
+ const validation = await validateIAP(iap.id);
147
+ validations.push(validation);
148
+ }
149
+
150
+ return validations;
151
+ }
152
+
153
+ /**
154
+ * Get IAP state description
155
+ */
156
+ export function getIAPStateDescription(state: IAPState): string {
157
+ const descriptions: Record<IAPState, string> = {
158
+ MISSING_METADATA: 'Missing required metadata',
159
+ READY_TO_SUBMIT: 'Ready to submit with next app version',
160
+ WAITING_FOR_REVIEW: 'Waiting for App Store review',
161
+ IN_REVIEW: 'Currently in review',
162
+ DEVELOPER_ACTION_NEEDED: 'Requires developer action',
163
+ PENDING_BINARY_APPROVAL: 'Pending binary approval',
164
+ APPROVED: 'Approved and active',
165
+ DEVELOPER_REMOVED_FROM_SALE: 'Removed from sale by developer',
166
+ REMOVED_FROM_SALE: 'Removed from sale',
167
+ REJECTED: 'Rejected by App Store review',
168
+ };
169
+
170
+ return descriptions[state] ?? state;
171
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * App Store Connect Screenshot Endpoints
3
+ */
4
+
5
+ import { getAllPages } from '../client.js';
6
+ import type {
7
+ AppScreenshotSet,
8
+ AppScreenshot,
9
+ ScreenshotDisplayType,
10
+ } from '../types.js';
11
+
12
+ /**
13
+ * Get all screenshot sets for a version localization
14
+ */
15
+ export async function getScreenshotSets(
16
+ versionLocalizationId: string
17
+ ): Promise<AppScreenshotSet[]> {
18
+ return getAllPages<AppScreenshotSet>(
19
+ `/appStoreVersionLocalizations/${versionLocalizationId}/appScreenshotSets`,
20
+ {
21
+ 'fields[appScreenshotSets]': 'screenshotDisplayType',
22
+ }
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Get screenshots in a screenshot set
28
+ */
29
+ export async function getScreenshots(screenshotSetId: string): Promise<AppScreenshot[]> {
30
+ return getAllPages<AppScreenshot>(
31
+ `/appScreenshotSets/${screenshotSetId}/appScreenshots`,
32
+ {
33
+ 'fields[appScreenshots]': 'fileSize,fileName,sourceFileChecksum,imageAsset,assetToken,assetType,assetDeliveryState',
34
+ }
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Get screenshot sets with their screenshots
40
+ */
41
+ export interface ScreenshotSetWithScreenshots {
42
+ set: AppScreenshotSet;
43
+ screenshots: AppScreenshot[];
44
+ }
45
+
46
+ export async function getScreenshotSetsWithScreenshots(
47
+ versionLocalizationId: string
48
+ ): Promise<ScreenshotSetWithScreenshots[]> {
49
+ const sets = await getScreenshotSets(versionLocalizationId);
50
+
51
+ const results: ScreenshotSetWithScreenshots[] = [];
52
+
53
+ for (const set of sets) {
54
+ const screenshots = await getScreenshots(set.id);
55
+ results.push({ set, screenshots });
56
+ }
57
+
58
+ return results;
59
+ }
60
+
61
+ /**
62
+ * Required screenshot display types for iOS submission
63
+ */
64
+ export const REQUIRED_IPHONE_DISPLAY_TYPES: ScreenshotDisplayType[] = [
65
+ 'APP_IPHONE_67', // iPhone 14 Pro Max (6.7")
66
+ 'APP_IPHONE_65', // iPhone 11 Pro Max (6.5")
67
+ 'APP_IPHONE_55', // iPhone 8 Plus (5.5")
68
+ ];
69
+
70
+ export const REQUIRED_IPAD_DISPLAY_TYPES: ScreenshotDisplayType[] = [
71
+ 'APP_IPAD_PRO_3GEN_129', // iPad Pro 12.9" (3rd gen)
72
+ 'APP_IPAD_PRO_129', // iPad Pro 12.9" (2nd gen)
73
+ ];
74
+
75
+ /**
76
+ * All commonly required display types
77
+ */
78
+ export const COMMONLY_REQUIRED_DISPLAY_TYPES: ScreenshotDisplayType[] = [
79
+ ...REQUIRED_IPHONE_DISPLAY_TYPES,
80
+ ...REQUIRED_IPAD_DISPLAY_TYPES,
81
+ ];
82
+
83
+ /**
84
+ * Get display type description for human-readable output
85
+ */
86
+ export function getDisplayTypeDescription(displayType: ScreenshotDisplayType): string {
87
+ const descriptions: Record<ScreenshotDisplayType, string> = {
88
+ APP_IPHONE_67: 'iPhone 6.7" (14 Pro Max, 15 Pro Max)',
89
+ APP_IPHONE_65: 'iPhone 6.5" (11 Pro Max, XS Max)',
90
+ APP_IPHONE_61: 'iPhone 6.1" (14/15, 14/15 Pro)',
91
+ APP_IPHONE_58: 'iPhone 5.8" (X, XS, 11 Pro)',
92
+ APP_IPHONE_55: 'iPhone 5.5" (6 Plus, 7 Plus, 8 Plus)',
93
+ APP_IPHONE_47: 'iPhone 4.7" (6, 7, 8, SE)',
94
+ APP_IPHONE_40: 'iPhone 4" (5, 5s, SE 1st gen)',
95
+ APP_IPHONE_35: 'iPhone 3.5" (4s and earlier)',
96
+ APP_IPAD_PRO_3GEN_129: 'iPad Pro 12.9" (3rd gen+)',
97
+ APP_IPAD_PRO_3GEN_11: 'iPad Pro 11"',
98
+ APP_IPAD_PRO_129: 'iPad Pro 12.9" (2nd gen)',
99
+ APP_IPAD_105: 'iPad 10.5"',
100
+ APP_IPAD_97: 'iPad 9.7"',
101
+ APP_WATCH_ULTRA: 'Apple Watch Ultra',
102
+ APP_WATCH_SERIES_7: 'Apple Watch Series 7+',
103
+ APP_WATCH_SERIES_4: 'Apple Watch Series 4-6',
104
+ APP_WATCH_SERIES_3: 'Apple Watch Series 3',
105
+ APP_DESKTOP: 'Mac Desktop',
106
+ APP_APPLE_TV: 'Apple TV',
107
+ };
108
+
109
+ return descriptions[displayType] ?? displayType;
110
+ }
111
+
112
+ /**
113
+ * Check screenshot validity
114
+ */
115
+ export interface ScreenshotValidation {
116
+ displayType: ScreenshotDisplayType;
117
+ count: number;
118
+ isValid: boolean;
119
+ hasProcessingErrors: boolean;
120
+ processingState: string[];
121
+ issues: string[];
122
+ }
123
+
124
+ export function validateScreenshotSet(
125
+ set: AppScreenshotSet,
126
+ screenshots: AppScreenshot[]
127
+ ): ScreenshotValidation {
128
+ const issues: string[] = [];
129
+ const processingState: string[] = [];
130
+ let hasProcessingErrors = false;
131
+
132
+ // Check count (1-10 required)
133
+ if (screenshots.length === 0) {
134
+ issues.push('No screenshots uploaded');
135
+ } else if (screenshots.length > 10) {
136
+ issues.push(`Too many screenshots (${screenshots.length}/10 max)`);
137
+ }
138
+
139
+ // Check processing state
140
+ for (const screenshot of screenshots) {
141
+ const state = screenshot.attributes.assetDeliveryState?.state;
142
+ if (state) {
143
+ processingState.push(state);
144
+ if (state === 'FAILED') {
145
+ hasProcessingErrors = true;
146
+ const errors = screenshot.attributes.assetDeliveryState?.errors;
147
+ if (errors && errors.length > 0) {
148
+ issues.push(`Screenshot "${screenshot.attributes.fileName}" failed: ${errors[0]!.description}`);
149
+ } else {
150
+ issues.push(`Screenshot "${screenshot.attributes.fileName}" failed to process`);
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ return {
157
+ displayType: set.attributes.screenshotDisplayType,
158
+ count: screenshots.length,
159
+ isValid: issues.length === 0,
160
+ hasProcessingErrors,
161
+ processingState,
162
+ issues,
163
+ };
164
+ }