Compare commits

..
Author SHA1 Message Date
evanbaconandClaude Opus 4.6 59ac890d2a fix(metadata): improve review attachment pull behavior and document ASC limitation
The App Store Connect API does not expose a download URL for review
attachments (unlike AppScreenshot which has imageAsset.templateUrl).
This is a confirmed API limitation (expo/third-party#146), not a gap
in our implementation.

Changes:
- Remove directory/placeholder file creation during pull — only record
  the path in config for round-trip support
- Replace terse warning with informational message explaining WHY
  download is not possible and what the user should do
- Add code comment referencing the investigation (expo/third-party#146)
- Add test verifying no directory is created on disk during pull

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-10 13:36:07 -07:00
evanbaconandClaude Opus 4.6 1f4260cc47 feat(metadata): support App Store review attachment
Adds an `attachment` field to the `review` block of the Apple metadata
config so users can ship a single file (e.g. demo instructions PDF) to
the App Store review team alongside contact + demo account info. Push
uploads the file via `AppStoreReviewDetail.uploadAttachmentAsync` and
skips re-uploading when the local MD5 matches the existing attachment.
Pull records the existing attachment's filename under
`store/apple/review-attachment/` (App Store Connect does not currently
expose a download URL for review attachments, so we cannot fetch the
bytes back).

Removes the `// TODO: add attachment` comments in
`apple/config/reader.ts` and `apple/config/writer.ts`.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 16:41:08 -07:00
12 changed files with 462 additions and 941 deletions

No files matched your search

+4 -116
View File
@@ -685,6 +685,10 @@
"markdownDescription": "Additional information about your app that can help during the review process.\n\n**Do not include demo account details.**",
"minLength": 2,
"maxLength": 4000
},
"attachment": {
"type": "string",
"description": "Relative path (from project root) to a file shared with the App Store review team. The file is uploaded as an App Store review attachment and only re-uploaded when its content changes."
}
}
},
@@ -800,118 +804,6 @@
"vi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Vietnamese" }
}
},
"ApplePrivacy": {
"type": "object",
"additionalProperties": false,
"description": "Privacy metadata. Currently only contains App Privacy Nutrition Labels (data usage).",
"properties": {
"dataUsage": {
"$ref": "#/definitions/apple/AppleDataUsage",
"description": "App Privacy Nutrition Labels. Declares what data the app collects, how it is used, and how it is linked to the user. Required for new app submissions since 2021."
}
}
},
"AppleDataUsage": {
"type": "object",
"additionalProperties": false,
"description": "Privacy Nutrition Labels — declarative declaration of data the app collects. When `dataNotCollected` is true, no data is collected and `categories` must be empty.",
"properties": {
"dataNotCollected": {
"type": "boolean",
"description": "Whether the app collects no data at all. Mirrors Apple's \"Data Not Collected\" toggle. When true, `categories` must be empty."
},
"categories": {
"type": "array",
"description": "Per-category data usage declarations.",
"items": {
"$ref": "#/definitions/apple/AppleDataUsageCategoryEntry"
}
}
}
},
"AppleDataUsageCategoryEntry": {
"type": "object",
"additionalProperties": false,
"required": ["category"],
"description": "A single Privacy Nutrition Labels declaration: pairs a data category with the purposes it is used for and the data protection / linkage classification.",
"properties": {
"category": {
"$ref": "#/definitions/apple/AppleDataUsageCategoryId",
"description": "Apple data category."
},
"purposes": {
"type": "array",
"description": "Purposes the category is used for. At least one entry is required when the category is collected.",
"items": {
"$ref": "#/definitions/apple/AppleDataUsagePurposeId"
}
},
"protections": {
"type": "array",
"description": "Data protection / linkage classification(s). A single category can be both linked to the user and used for tracking.",
"items": {
"$ref": "#/definitions/apple/AppleDataUsageDataProtectionId"
}
}
}
},
"AppleDataUsageCategoryId": {
"enum": [
"ADVERTISING_DATA",
"AUDIO",
"BROWSING_HISTORY",
"COARSE_LOCATION",
"CONTACTS",
"CRASH_DATA",
"CREDIT_AND_FRAUD",
"CUSTOMER_SUPPORT",
"DEVICE_ID",
"EMAIL_ADDRESS",
"EMAILS_OR_TEXT_MESSAGES",
"ENVIRONMENTAL_SCANNING",
"FITNESS",
"GAMEPLAY_CONTENT",
"HANDS",
"HEAD_MOVEMENT",
"HEALTH",
"NAME",
"OTHER_CONTACT_INFO",
"OTHER_DATA",
"OTHER_DIAGNOSTIC_DATA",
"OTHER_FINANCIAL_INFO",
"OTHER_USAGE_DATA",
"OTHER_USER_CONTENT",
"PAYMENT_INFORMATION",
"PERFORMANCE_DATA",
"PHONE_NUMBER",
"PHOTOS_OR_VIDEOS",
"PHYSICAL_ADDRESS",
"PRECISE_LOCATION",
"PRODUCT_INTERACTION",
"PURCHASE_HISTORY",
"SEARCH_HISTORY",
"SENSITIVE_INFO",
"USER_ID"
]
},
"AppleDataUsagePurposeId": {
"enum": [
"THIRD_PARTY_ADVERTISING",
"DEVELOPERS_ADVERTISING",
"ANALYTICS",
"PRODUCT_PERSONALIZATION",
"APP_FUNCTIONALITY",
"OTHER_PURPOSES"
]
},
"AppleDataUsageDataProtectionId": {
"enum": [
"DATA_USED_TO_TRACK_YOU",
"DATA_LINKED_TO_YOU",
"DATA_NOT_LINKED_TO_YOU",
"DATA_NOT_COLLECTED"
]
},
"AppleAgeRatingOverride": {
"enum": [
"NONE",
@@ -1146,10 +1038,6 @@
"appClip": {
"$ref": "#/definitions/apple/AppleAppClip",
"description": "App Clip metadata. Only applies to apps that ship an App Clip target."
},
"privacy": {
"$ref": "#/definitions/apple/ApplePrivacy",
"description": "Privacy metadata, including App Privacy Nutrition Labels (data usage). Required for new app submissions since 2021."
}
}
}
@@ -216,3 +216,35 @@ describe('getAppClip', () => {
expect(reader.getAppClipLocalizedInfo('de-DE')).toBeNull();
});
});
describe('getReviewAttachment', () => {
it('returns null when review is not configured', () => {
const reader = new AppleConfigReader({});
expect(reader.getReviewAttachment()).toBeNull();
});
it('returns null when review has no attachment', () => {
const reader = new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: '[email protected]',
phone: '+1 555 555 5555',
},
});
expect(reader.getReviewAttachment()).toBeNull();
});
it('returns the configured attachment path', () => {
const reader = new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: '[email protected]',
phone: '+1 555 555 5555',
attachment: 'store/apple/review-attachment/demo.pdf',
},
});
expect(reader.getReviewAttachment()).toBe('store/apple/review-attachment/demo.pdf');
});
});
@@ -345,6 +345,30 @@ describe('setReviewDetails', () => {
demoRequired: undefined,
});
});
it('preserves an existing review attachment when re-setting review details', () => {
const writer = new AppleConfigWriter();
writer.setReviewDetails(nameOnlyReviewDetails);
writer.setReviewAttachment('store/apple/review-attachment/demo.pdf');
writer.setReviewDetails(nameAndDemoReviewDetails);
expect(writer.schema.review?.attachment).toBe('store/apple/review-attachment/demo.pdf');
});
});
describe('setReviewAttachment', () => {
it('initializes a review block with the attachment path', () => {
const writer = new AppleConfigWriter();
writer.setReviewAttachment('store/apple/review-attachment/demo.pdf');
expect(writer.schema.review?.attachment).toBe('store/apple/review-attachment/demo.pdf');
});
it('clears the attachment when null is passed', () => {
const writer = new AppleConfigWriter();
writer.setReviewDetails(nameOnlyReviewDetails);
writer.setReviewAttachment('store/apple/review-attachment/demo.pdf');
writer.setReviewAttachment(null);
expect(writer.schema.review?.attachment).toBeUndefined();
});
});
describe('setAppClipDefaultExperience', () => {
@@ -20,7 +20,6 @@ import {
AppleAppClip,
AppleAppClipDefaultExperience,
AppleAppClipLocalizedInfo,
AppleDataUsage,
AppleMetadata,
ApplePreviews,
AppleScreenshots,
@@ -225,10 +224,14 @@ export class AppleConfigReader {
demoAccountPassword: review.demoPassword,
demoAccountRequired: review.demoRequired,
notes: review.notes,
// TODO: add attachment
};
}
/** Get the configured review attachment path (relative to the project root), or null if not set. */
public getReviewAttachment(): string | null {
return this.schema.review?.attachment ?? null;
}
/** Get screenshots configuration for a specific locale */
public getScreenshots(locale: string): AppleScreenshots | null {
return this.schema.info?.[locale]?.screenshots ?? null;
@@ -258,12 +261,4 @@ export class AppleConfigReader {
public getAppClipLocalizedInfo(locale: string): AppleAppClipLocalizedInfo | null {
return this.schema.appClip?.defaultExperience?.info?.[locale] ?? null;
}
/**
* Get the privacy data usage block (Privacy Nutrition Labels), or `null` if
* not configured. When `null`, the data usage task is a no-op.
*/
public getDataUsage(): AppleDataUsage | null {
return this.schema.privacy?.dataUsage ?? null;
}
}
@@ -17,7 +17,6 @@ import {
AppleAppClipDefaultExperience,
AppleAppClipLocalizedInfo,
AppleAppClipReviewDetail,
AppleDataUsage,
AppleMetadata,
ApplePreviews,
AppleScreenshots,
@@ -191,6 +190,7 @@ export class AppleConfigWriter {
}
public setReviewDetails(attributes: AttributesOf<AppStoreReviewDetail>): void {
const existingAttachment = this.schema.review?.attachment;
this.schema.review = {
firstName: attributes.contactFirstName ?? '',
lastName: attributes.contactLastName ?? '',
@@ -200,10 +200,25 @@ export class AppleConfigWriter {
demoPassword: optional(attributes.demoAccountPassword),
demoRequired: optional(attributes.demoAccountRequired),
notes: optional(attributes.notes),
// TODO: add attachment
attachment: existingAttachment,
};
}
/** Set the review attachment path (relative to project root) on the review block. */
public setReviewAttachment(attachmentPath: string | null): void {
this.schema.review = this.schema.review ?? {
firstName: '',
lastName: '',
email: '',
phone: '',
};
if (attachmentPath) {
this.schema.review.attachment = attachmentPath;
} else {
delete this.schema.review.attachment;
}
}
/** Set screenshots for a specific locale */
public setScreenshots(locale: string, screenshots: AppleScreenshots): void {
this.schema.info = this.schema.info ?? {};
@@ -219,24 +234,6 @@ export class AppleConfigWriter {
this.schema.info[locale].previews = Object.keys(previews).length > 0 ? previews : undefined;
}
/**
* Set the privacy data usage block. Pass `null` to clear it (e.g. when the
* remote app has no data usage entries declared yet).
*/
public setDataUsage(dataUsage: AppleDataUsage | null): void {
if (!dataUsage) {
if (this.schema.privacy) {
delete this.schema.privacy.dataUsage;
if (Object.keys(this.schema.privacy).length === 0) {
delete this.schema.privacy;
}
}
return;
}
this.schema.privacy = this.schema.privacy ?? {};
this.schema.privacy.dataUsage = dataUsage;
}
/** Set the App Clip default experience attributes (action, releaseWithAppStoreVersion). */
public setAppClipDefaultExperience(
attributes: Pick<AppleAppClipDefaultExperience, 'action' | 'releaseWithAppStoreVersion'>
+1 -3
View File
@@ -5,7 +5,6 @@ import type { AppClipData } from './tasks/app-clip';
import type { AppInfoData } from './tasks/app-info';
import type { AppReviewData } from './tasks/app-review-detail';
import type { AppVersionData } from './tasks/app-version';
import type { DataUsageData } from './tasks/data-usage';
import type { PreviewsData } from './tasks/previews';
import type { ScreenshotsData } from './tasks/screenshots';
@@ -19,8 +18,7 @@ export type AppleData = { app: App; projectDir: string } & AppInfoData &
AppReviewData &
ScreenshotsData &
PreviewsData &
AppClipData &
DataUsageData;
AppClipData;
/**
* The unprepared partial apple data, used within the `prepareAsync` tasks.
@@ -1,4 +1,6 @@
import { AppStoreReviewAttachment, AppStoreReviewDetail, AppStoreVersion } from '@expo/apple-utils';
import crypto from 'crypto';
import fs from 'fs';
import nock from 'nock';
import { requestContext } from './fixtures/requestContext';
@@ -9,6 +11,10 @@ import { AppReviewDetailTask } from '../app-review-detail';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
function md5(value: Buffer | string): string {
return crypto.createHash('md5').update(value).digest('hex');
}
describe(AppReviewDetailTask, () => {
describe('prepareAsync', () => {
it('loads review details from app version', async () => {
@@ -51,6 +57,78 @@ describe(AppReviewDetailTask, () => {
expect(writer.setReviewDetails).not.toBeCalled();
});
it('records the review attachment placeholder path when one exists', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const attachment = new AppStoreReviewAttachment(requestContext, 'ATTACH_1', {
fileName: 'demo-instructions.pdf',
fileSize: 1024,
sourceFileChecksum: 'abc123',
uploaded: true,
} as any);
const reviewDetail = new AppStoreReviewDetail(requestContext, 'stub-id', {
appStoreReviewAttachments: [attachment],
} as any);
const existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(true);
await new AppReviewDetailTask().downloadAsync({
config: writer,
context: { reviewDetail, projectDir: '/test/project' } as any,
});
expect(writer.setReviewDetails).toBeCalledWith(reviewDetail.attributes);
expect(writer.setReviewAttachment).toBeCalledWith(
'store/apple/review-attachment/demo-instructions.pdf'
);
existsSyncSpy.mockRestore();
});
it('logs an info message when attachment exists remotely but not on disk', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const attachment = new AppStoreReviewAttachment(requestContext, 'ATTACH_1', {
fileName: 'demo-instructions.pdf',
fileSize: 1024,
sourceFileChecksum: 'abc123',
uploaded: true,
} as any);
const reviewDetail = new AppStoreReviewDetail(requestContext, 'stub-id', {
appStoreReviewAttachments: [attachment],
} as any);
const existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(false);
const mkdirSpy = jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
await new AppReviewDetailTask().downloadAsync({
config: writer,
context: { reviewDetail, projectDir: '/test/project' } as any,
});
// Should still record the path in config for round-trip support
expect(writer.setReviewAttachment).toBeCalledWith(
'store/apple/review-attachment/demo-instructions.pdf'
);
// Should NOT create directories on disk (no placeholder file)
expect(mkdirSpy).not.toBeCalled();
existsSyncSpy.mockRestore();
mkdirSpy.mockRestore();
});
it('does not call setReviewAttachment when there is no attachment', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const reviewDetail = new AppStoreReviewDetail(requestContext, 'stub-id', {
appStoreReviewAttachments: [],
} as any);
await new AppReviewDetailTask().downloadAsync({
config: writer,
context: { reviewDetail, projectDir: '/test/project' } as any,
});
expect(writer.setReviewAttachment).not.toBeCalled();
});
});
describe('uploadAsync', () => {
@@ -141,5 +219,182 @@ describe(AppReviewDetailTask, () => {
expect(createReviewDetailScope.isDone()).toBeTruthy();
expect(updateReviewDetailScope.isDone()).toBeTruthy();
});
describe('attachment sync', () => {
const fileBytes = Buffer.from('file-bytes');
const fileChecksum = md5(fileBytes);
let existsSyncSpy: jest.SpyInstance;
let readFileSyncSpy: jest.SpyInstance;
beforeEach(() => {
existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(true);
readFileSyncSpy = jest.spyOn(fs, 'readFileSync').mockReturnValue(fileBytes as any);
});
afterEach(() => {
existsSyncSpy.mockRestore();
readFileSyncSpy.mockRestore();
jest.restoreAllMocks();
nock.cleanAll();
});
it('uploads a new attachment when none exists', async () => {
nock('https://api.appstoreconnect.apple.com')
.patch(`/v1/${AppStoreReviewDetail.type}/APP_STORE_REVIEW_DETAILS_1`)
.reply(200, require('./fixtures/appStoreReviewDetails/patch-200.json'));
const reviewDetail = new AppStoreReviewDetail(
requestContext,
'APP_STORE_REVIEW_DETAILS_1',
{ appStoreReviewAttachments: [] } as any
);
const uploadSpy = jest
.spyOn(AppStoreReviewDetail.prototype, 'uploadAttachmentAsync')
.mockResolvedValue({} as any);
await new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: '[email protected]',
phone: '+1 555 555 5555',
attachment: 'store/apple/review-attachment/demo.pdf',
},
}),
context: {
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
reviewDetail,
projectDir: '/test/project',
} as any,
});
expect(uploadSpy).toBeCalledTimes(1);
expect(uploadSpy).toBeCalledWith(
expect.stringContaining('store/apple/review-attachment/demo.pdf')
);
});
it('skips upload when checksum matches existing attachment', async () => {
nock('https://api.appstoreconnect.apple.com')
.patch(`/v1/${AppStoreReviewDetail.type}/APP_STORE_REVIEW_DETAILS_1`)
.reply(200, require('./fixtures/appStoreReviewDetails/patch-200.json'));
const existing = new AppStoreReviewAttachment(requestContext, 'ATTACH_1', {
fileName: 'demo.pdf',
fileSize: fileBytes.length,
sourceFileChecksum: fileChecksum,
uploaded: true,
} as any);
const reviewDetail = new AppStoreReviewDetail(
requestContext,
'APP_STORE_REVIEW_DETAILS_1',
{ appStoreReviewAttachments: [existing] } as any
);
const uploadSpy = jest
.spyOn(AppStoreReviewDetail.prototype, 'uploadAttachmentAsync')
.mockResolvedValue({} as any);
const deleteSpy = jest.spyOn(existing, 'deleteAsync').mockResolvedValue(undefined);
await new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: '[email protected]',
phone: '+1 555 555 5555',
attachment: 'store/apple/review-attachment/demo.pdf',
},
}),
context: {
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
reviewDetail,
projectDir: '/test/project',
} as any,
});
expect(uploadSpy).not.toBeCalled();
expect(deleteSpy).not.toBeCalled();
});
it('replaces stale attachment when checksum differs', async () => {
nock('https://api.appstoreconnect.apple.com')
.patch(`/v1/${AppStoreReviewDetail.type}/APP_STORE_REVIEW_DETAILS_1`)
.reply(200, require('./fixtures/appStoreReviewDetails/patch-200.json'));
const existing = new AppStoreReviewAttachment(requestContext, 'ATTACH_OLD', {
fileName: 'old.pdf',
fileSize: 5,
sourceFileChecksum: 'different-checksum',
uploaded: true,
} as any);
const reviewDetail = new AppStoreReviewDetail(
requestContext,
'APP_STORE_REVIEW_DETAILS_1',
{ appStoreReviewAttachments: [existing] } as any
);
const uploadSpy = jest
.spyOn(AppStoreReviewDetail.prototype, 'uploadAttachmentAsync')
.mockResolvedValue({} as any);
const deleteSpy = jest.spyOn(existing, 'deleteAsync').mockResolvedValue(undefined);
await new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: '[email protected]',
phone: '+1 555 555 5555',
attachment: 'store/apple/review-attachment/demo.pdf',
},
}),
context: {
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
reviewDetail,
projectDir: '/test/project',
} as any,
});
expect(deleteSpy).toBeCalledTimes(1);
expect(uploadSpy).toBeCalledTimes(1);
});
it('warns and skips when local attachment file is missing', async () => {
nock('https://api.appstoreconnect.apple.com')
.patch(`/v1/${AppStoreReviewDetail.type}/APP_STORE_REVIEW_DETAILS_1`)
.reply(200, require('./fixtures/appStoreReviewDetails/patch-200.json'));
existsSyncSpy.mockReturnValue(false);
const reviewDetail = new AppStoreReviewDetail(
requestContext,
'APP_STORE_REVIEW_DETAILS_1',
{ appStoreReviewAttachments: [] } as any
);
const uploadSpy = jest
.spyOn(AppStoreReviewDetail.prototype, 'uploadAttachmentAsync')
.mockResolvedValue({} as any);
await new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: '[email protected]',
phone: '+1 555 555 5555',
attachment: 'store/apple/review-attachment/missing.pdf',
},
}),
context: {
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
reviewDetail,
projectDir: '/test/project',
} as any,
});
expect(uploadSpy).not.toBeCalled();
});
});
});
});
@@ -1,403 +0,0 @@
import {
App,
AppDataUsage,
AppDataUsageCategoryId,
AppDataUsageDataProtectionId,
AppDataUsagePurposeId,
AppDataUsagesPublishState,
} from '@expo/apple-utils';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { AppleData } from '../../data';
import { DataUsageTask } from '../data-usage';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
function makeRow(
id: string,
category: AppDataUsageCategoryId | null,
purpose: AppDataUsagePurposeId | null,
protection: AppDataUsageDataProtectionId | null
): AppDataUsage {
const row = new AppDataUsage(requestContext, id, {
category: category ? ({ id: category } as any) : undefined,
purpose: purpose ? ({ id: purpose } as any) : undefined,
dataProtection: protection ? ({ id: protection } as any) : undefined,
} as any);
// Stub deleteAsync so the diff path doesn't actually hit the network.
(row as any).deleteAsync = jest.fn().mockResolvedValue(undefined);
return row;
}
function makeApp(): App {
const app = new App(requestContext, 'app-id', {} as any);
(app as any).getAppDataUsagesAsync = jest.fn().mockResolvedValue([]);
(app as any).getAppDataUsagesPublishStateAsync = jest.fn().mockResolvedValue([]);
(app as any).createAppDataUsageAsync = jest.fn(async (params: any) => {
return makeRow(
`created-${params.appDataUsageCategory ?? 'none'}-${params.appDataUsagePurpose ?? 'none'}-${
params.appDataUsageProtection ?? 'none'
}`,
params.appDataUsageCategory ?? null,
params.appDataUsagePurpose ?? null,
params.appDataUsageProtection ?? null
);
});
return app;
}
function makePublishState(): AppDataUsagesPublishState {
const state = new AppDataUsagesPublishState(requestContext, 'app-id', {
published: false,
lastPublished: '',
lastPublishedBy: '',
} as any);
(state as any).updateAsync = jest.fn().mockResolvedValue(state);
return state;
}
describe(DataUsageTask, () => {
describe('prepareAsync', () => {
it('loads existing data usage rows and publish state', async () => {
const app = makeApp();
const existing = [
makeRow(
'row-1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
];
(app as any).getAppDataUsagesAsync.mockResolvedValue(existing);
const publishState = makePublishState();
(app as any).getAppDataUsagesPublishStateAsync.mockResolvedValue([publishState]);
const context: any = { app, projectDir: '/p' };
await new DataUsageTask().prepareAsync({ context });
expect(context.dataUsages).toBe(existing);
expect(context.dataUsagesPublishState).toBe(publishState);
});
it('treats 404s as empty state', async () => {
const app = makeApp();
(app as any).getAppDataUsagesAsync.mockRejectedValue({ response: { status: 404 } });
(app as any).getAppDataUsagesPublishStateAsync.mockRejectedValue({
response: { status: 404 },
});
const context: any = { app, projectDir: '/p' };
await new DataUsageTask().prepareAsync({ context });
expect(context.dataUsages).toEqual([]);
expect(context.dataUsagesPublishState).toBeNull();
});
});
describe('downloadAsync', () => {
it('writes nothing when there are no rows', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new DataUsageTask().downloadAsync({
config: writer,
context: { dataUsages: [], dataUsagesPublishState: null } as any,
});
expect(writer.setDataUsage).toHaveBeenCalledWith(null);
});
it('collapses a single row into a category entry', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new DataUsageTask().downloadAsync({
config: writer,
context: {
dataUsages: [
makeRow(
'row-1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
],
dataUsagesPublishState: null,
} as any,
});
expect(writer.setDataUsage).toHaveBeenCalledWith({
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
});
});
it('groups multiple rows by category and dedupes purposes/protections', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new DataUsageTask().downloadAsync({
config: writer,
context: {
dataUsages: [
makeRow(
'r1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
makeRow(
'r2',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.APP_FUNCTIONALITY,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
makeRow(
'r3',
AppDataUsageCategoryId.PRECISE_LOCATION,
AppDataUsagePurposeId.APP_FUNCTIONALITY,
AppDataUsageDataProtectionId.DATA_NOT_LINKED_TO_YOU
),
],
dataUsagesPublishState: null,
} as any,
});
const arg = (writer.setDataUsage as jest.Mock).mock.calls[0][0];
expect(arg.categories).toHaveLength(2);
const contacts = arg.categories.find((c: any) => c.category === 'CONTACTS');
expect(contacts.purposes.sort()).toEqual(['ANALYTICS', 'APP_FUNCTIONALITY']);
expect(contacts.protections).toEqual(['DATA_LINKED_TO_YOU']);
const location = arg.categories.find((c: any) => c.category === 'PRECISE_LOCATION');
expect(location.purposes).toEqual(['APP_FUNCTIONALITY']);
expect(location.protections).toEqual(['DATA_NOT_LINKED_TO_YOU']);
});
it('emits dataNotCollected when only the sentinel row is present', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new DataUsageTask().downloadAsync({
config: writer,
context: {
dataUsages: [makeRow('r1', null, null, AppDataUsageDataProtectionId.DATA_NOT_COLLECTED)],
dataUsagesPublishState: null,
} as any,
});
expect(writer.setDataUsage).toHaveBeenCalledWith({ dataNotCollected: true });
});
});
describe('uploadAsync', () => {
it('skips when no data usage is configured', async () => {
const app = makeApp();
const ctx: AppleData = {
app,
projectDir: '/p',
dataUsages: [],
dataUsagesPublishState: null,
} as any;
await new DataUsageTask().uploadAsync({
config: new AppleConfigReader({}),
context: ctx,
});
expect((app as any).createAppDataUsageAsync).not.toHaveBeenCalled();
});
it('creates rows for each (category × purpose × protection) tuple', async () => {
const app = makeApp();
const publishState = makePublishState();
const ctx: AppleData = {
app,
projectDir: '/p',
dataUsages: [],
dataUsagesPublishState: publishState,
} as any;
await new DataUsageTask().uploadAsync({
config: new AppleConfigReader({
privacy: {
dataUsage: {
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS', 'APP_FUNCTIONALITY'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
},
},
}),
context: ctx,
});
expect((app as any).createAppDataUsageAsync).toHaveBeenCalledTimes(2);
expect((publishState as any).updateAsync).toHaveBeenCalledWith({ published: true });
});
it('deletes rows that are no longer in the config and creates new ones', async () => {
const app = makeApp();
const publishState = makePublishState();
const stale = makeRow(
'stale',
AppDataUsageCategoryId.PRECISE_LOCATION,
AppDataUsagePurposeId.OTHER_PURPOSES,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
);
const kept = makeRow(
'kept',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
);
(app as any).getAppDataUsagesAsync.mockResolvedValue([stale, kept]);
const ctx: AppleData = {
app,
projectDir: '/p',
dataUsages: [stale, kept],
dataUsagesPublishState: publishState,
} as any;
await new DataUsageTask().uploadAsync({
config: new AppleConfigReader({
privacy: {
dataUsage: {
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS'],
protections: ['DATA_LINKED_TO_YOU'],
},
{
category: 'EMAIL_ADDRESS',
purposes: ['APP_FUNCTIONALITY'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
},
},
}),
context: ctx,
});
// stale row deleted, EMAIL_ADDRESS row created, CONTACTS untouched.
expect((stale as any).deleteAsync).toHaveBeenCalled();
expect((kept as any).deleteAsync).not.toHaveBeenCalled();
expect((app as any).createAppDataUsageAsync).toHaveBeenCalledTimes(1);
expect((app as any).createAppDataUsageAsync).toHaveBeenCalledWith({
appDataUsageCategory: 'EMAIL_ADDRESS',
appDataUsagePurpose: 'APP_FUNCTIONALITY',
appDataUsageProtection: 'DATA_LINKED_TO_YOU',
});
expect((publishState as any).updateAsync).toHaveBeenCalledWith({ published: true });
});
it('makes no API calls when the config matches existing rows exactly', async () => {
const app = makeApp();
const publishState = makePublishState();
const existing = makeRow(
'r1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
);
const ctx: AppleData = {
app,
projectDir: '/p',
dataUsages: [existing],
dataUsagesPublishState: publishState,
} as any;
await new DataUsageTask().uploadAsync({
config: new AppleConfigReader({
privacy: {
dataUsage: {
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
},
},
}),
context: ctx,
});
expect((existing as any).deleteAsync).not.toHaveBeenCalled();
expect((app as any).createAppDataUsageAsync).not.toHaveBeenCalled();
// Publish state is still flipped to ensure published.
expect((publishState as any).updateAsync).toHaveBeenCalledWith({ published: true });
});
});
describe('round-trip', () => {
it('downloads then uploads without making mutation calls', async () => {
// Use the real writer (not the auto-mock) so we can read back the
// serialized schema and feed it into the upload path.
const RealWriter = jest.requireActual('../../config/writer')
.AppleConfigWriter as typeof AppleConfigWriter;
// Pull side: collapse rows into config.
const writer = new RealWriter();
const downloadApp = makeApp();
const rows = [
makeRow(
'r1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
makeRow(
'r2',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.APP_FUNCTIONALITY,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
];
await new DataUsageTask().downloadAsync({
config: writer,
context: { app: downloadApp, dataUsages: rows, dataUsagesPublishState: null } as any,
});
// The serialized config should now contain the rows.
expect(writer.schema.privacy?.dataUsage).toEqual({
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS', 'APP_FUNCTIONALITY'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
});
// Push side: feed the config back through the reader and verify nothing
// is mutated since the existing rows already match.
const uploadApp = makeApp();
const publishState = makePublishState();
const reader = new AppleConfigReader(writer.schema);
await new DataUsageTask().uploadAsync({
config: reader,
context: {
app: uploadApp,
projectDir: '/p',
dataUsages: rows,
dataUsagesPublishState: publishState,
} as any,
});
expect((uploadApp as any).createAppDataUsageAsync).not.toHaveBeenCalled();
for (const row of rows) {
expect((row as any).deleteAsync).not.toHaveBeenCalled();
}
});
});
});
@@ -1,5 +1,8 @@
import { AppStoreReviewDetail } from '@expo/apple-utils';
import { AppStoreReviewAttachment, AppStoreReviewDetail } from '@expo/apple-utils';
import chalk from 'chalk';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import Log from '../../../log';
import { logAsync } from '../../utils/log';
@@ -10,6 +13,14 @@ export type AppReviewData = {
reviewDetail: AppStoreReviewDetail;
};
/** Default directory (relative to project root) for downloaded review attachments. */
const REVIEW_ATTACHMENT_DIR = path.join('store', 'apple', 'review-attachment');
function md5OfFile(absolutePath: string): string {
const bytes = fs.readFileSync(absolutePath);
return crypto.createHash('md5').update(bytes).digest('hex');
}
/** Handle all contact, demo account, or instruction info that are required for the App Store review team. */
export class AppReviewDetailTask extends AppleTask {
public name = (): string => 'app review detail';
@@ -22,9 +33,41 @@ export class AppReviewDetailTask extends AppleTask {
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
if (context.reviewDetail) {
config.setReviewDetails(context.reviewDetail.attributes);
if (!context.reviewDetail) {
return;
}
config.setReviewDetails(context.reviewDetail.attributes);
// ASC limitation — see expo/third-party#146. AppStoreReviewAttachment has no
// download URL (contrast with AppScreenshot.imageAsset.templateUrl). The API
// only exposes fileName, fileSize, sourceFileChecksum, uploadOperations, and
// assetDeliveryState. We record the expected path in the config so that the
// pull -> push round-trip preserves the attachment entry, but we cannot
// download the actual file bytes.
const attachments = context.reviewDetail.attributes.appStoreReviewAttachments ?? [];
const attachment = attachments[0];
if (!attachment) {
return;
}
const fileName = attachment.attributes.fileName || 'attachment';
const relativePath = path.join(REVIEW_ATTACHMENT_DIR, fileName);
const absolutePath = path.resolve(context.projectDir, relativePath);
if (!fs.existsSync(absolutePath)) {
Log.log(
chalk`{yellow Review attachment "${chalk.bold(
fileName
)}" exists in App Store Connect but cannot be downloaded.}\n` +
chalk` App Store Connect does not expose a download URL for review attachments\n` +
chalk` (unlike screenshots which have {dim imageAsset.templateUrl}). Only {dim fileName},\n` +
chalk` {dim fileSize}, and {dim sourceFileChecksum} are available from the API.\n` +
chalk` To keep the attachment in sync, place the file at: {bold ${relativePath}}`
);
}
config.setReviewAttachment(relativePath);
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
@@ -54,10 +97,82 @@ export class AppReviewDetailTask extends AppleTask {
);
}
// Capture existing attachments before the patch response overwrites them. The
// PATCH response doesn't include the related `appStoreReviewAttachments`, so
// we need to reuse the version we fetched in `prepareAsync` to make a
// checksum-based skip decision below.
const existingAttachments = context.reviewDetail.attributes.appStoreReviewAttachments ?? [];
context.reviewDetail = await logAsync(() => context.reviewDetail.updateAsync(reviewDetail), {
pending: `Updating store review details for ${chalk.bold(versionString)}...`,
success: `Updated store review details for ${chalk.bold(versionString)}`,
failure: `Failed updating store review details for ${chalk.bold(versionString)}`,
});
await this.syncAttachmentAsync({
attachmentPath: config.getReviewAttachment(),
reviewDetail: context.reviewDetail,
existingAttachments,
projectDir: context.projectDir,
});
}
/**
* Sync the App Store review attachment.
* - When no attachment is configured, do nothing (existing remote attachments are left alone).
* - When the configured file matches an existing attachment by checksum, skip re-upload.
* - Otherwise delete any existing attachment(s) and upload the new file.
*/
private async syncAttachmentAsync({
attachmentPath,
reviewDetail,
existingAttachments,
projectDir,
}: {
attachmentPath: string | null;
reviewDetail: AppStoreReviewDetail;
existingAttachments: AppStoreReviewAttachment[];
projectDir: string;
}): Promise<void> {
if (!attachmentPath) {
return;
}
const absolutePath = path.resolve(projectDir, attachmentPath);
if (!fs.existsSync(absolutePath)) {
Log.warn(chalk`{yellow Review attachment not found: ${absolutePath}}`);
return;
}
const fileName = path.basename(absolutePath);
const localChecksum = md5OfFile(absolutePath);
const matching = existingAttachments.find(
attachment =>
attachment.attributes.uploaded &&
attachment.attributes.sourceFileChecksum === localChecksum
);
if (matching) {
Log.log(chalk`{dim - Skipped review attachment ${fileName}, already up to date}`);
return;
}
// Remove any stale attachments before uploading the new one. App Store
// Connect generally allows a single review attachment per review detail,
// so we replace whatever is currently there.
for (const attachment of existingAttachments) {
await logAsync(() => attachment.deleteAsync(), {
pending: `Deleting review attachment ${chalk.bold(attachment.attributes.fileName)}...`,
success: `Deleted review attachment ${chalk.bold(attachment.attributes.fileName)}`,
failure: `Failed deleting review attachment ${chalk.bold(attachment.attributes.fileName)}`,
});
}
await logAsync(() => reviewDetail.uploadAttachmentAsync(absolutePath), {
pending: `Uploading review attachment ${chalk.bold(fileName)}...`,
success: `Uploaded review attachment ${chalk.bold(fileName)}`,
failure: `Failed uploading review attachment ${chalk.bold(fileName)}`,
});
}
}
@@ -1,322 +0,0 @@
import {
AppDataUsage,
AppDataUsageCategoryId,
AppDataUsageDataProtectionId,
AppDataUsagePurposeId,
AppDataUsagesPublishState,
} from '@expo/apple-utils';
import chalk from 'chalk';
import Log from '../../../log';
import { logAsync } from '../../utils/log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
import {
AppleDataUsage,
AppleDataUsageCategoryEntry,
AppleDataUsageCategoryId as AppleDataUsageCategoryIdString,
AppleDataUsageDataProtectionId as AppleDataUsageDataProtectionIdString,
AppleDataUsagePurposeId as AppleDataUsagePurposeIdString,
} from '../types';
export type DataUsageData = {
/**
* Existing AppDataUsage rows on App Store Connect for the current app.
* One row per (category, purpose, dataProtection) tuple. Populated during
* `prepareAsync` and refreshed after `uploadAsync` makes mutations.
*/
dataUsages: AppDataUsage[];
/**
* The publish-state envelope for the data usage section. Apple gates the
* Privacy Nutrition Labels behind a separate `publish` flag — until this is
* set to `published: true`, the values authored above won't take effect on
* the storefront. The model is keyed by the App id (the IRIS endpoint is
* non-standard and there's only ever one).
*/
dataUsagesPublishState: AppDataUsagesPublishState | null;
};
/**
* Sync App Privacy Nutrition Labels (data usage) to/from App Store Connect.
*
* Apple has required every new app submission since 2021 to declare what data
* the app collects, the purposes it's used for, and how it's linked to the
* user. Each declaration is a tuple of (category, purpose, protection) — for
* example "CONTACTS used for ANALYTICS, linked to the user". The local config
* groups these by category for ergonomics; on push we expand each row into the
* cartesian product of (category × purpose × protection) and reconcile against
* the existing rows in App Store Connect.
*
* The task is declarative: any rows in ASC that aren't present in the local
* config are deleted, any rows in the local config that aren't in ASC are
* created, and existing rows are left untouched. After a successful push the
* publish-state envelope is flipped to `published: true` so the changes
* actually appear on the App Store.
*/
export class DataUsageTask extends AppleTask {
public name = (): string => 'data usage (privacy nutrition labels)';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
context.dataUsages = [];
context.dataUsagesPublishState = null;
try {
context.dataUsages = await context.app.getAppDataUsagesAsync();
} catch (error: any) {
// Apps that have never opened the App Privacy section yet may return
// 404 / NOT_FOUND from the iris endpoint. That's fine — treat it as
// "no rows" and continue. Anything else is unexpected and surfaces.
if (!isNotFoundError(error)) {
throw error;
}
}
try {
const states = await context.app.getAppDataUsagesPublishStateAsync();
context.dataUsagesPublishState = states[0] ?? null;
} catch (error: any) {
if (!isNotFoundError(error)) {
throw error;
}
}
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
const dataUsage = collapseDataUsageRows(context.dataUsages ?? []);
if (!dataUsage) {
// Don't write anything when the app has no rows declared at all — leave
// `privacy.dataUsage` absent in the schema rather than emitting an empty
// object that round-trips into a no-op publish.
config.setDataUsage(null);
return;
}
config.setDataUsage(dataUsage);
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
const desired = config.getDataUsage();
if (!desired) {
Log.log(chalk`{dim - Skipped data usage, not configured}`);
return;
}
const desiredRows = expandDataUsageRows(desired);
const existingRows = context.dataUsages ?? [];
// Build a stable key for each (category, purpose, protection) tuple so we
// can diff existing vs desired rows. Categories and protections may be
// null on the existing rows when the relationship isn't included.
const existingByKey = new Map<string, AppDataUsage>();
for (const row of existingRows) {
const key = rowKey(
(row.attributes.category?.id as AppDataUsageCategoryId | undefined) ?? null,
(row.attributes.purpose?.id as AppDataUsagePurposeId | undefined) ?? null,
(row.attributes.dataProtection?.id as AppDataUsageDataProtectionId | undefined) ?? null
);
existingByKey.set(key, row);
}
const desiredByKey = new Map<
string,
{
category: AppDataUsageCategoryId | null;
purpose: AppDataUsagePurposeId | null;
protection: AppDataUsageDataProtectionId | null;
}
>();
for (const row of desiredRows) {
desiredByKey.set(rowKey(row.category, row.purpose, row.protection), row);
}
// Delete rows that exist on ASC but aren't in the local config.
let deleted = 0;
for (const [key, row] of existingByKey) {
if (!desiredByKey.has(key)) {
await row.deleteAsync();
deleted += 1;
}
}
// Create rows that are in the local config but missing from ASC.
let created = 0;
const createdRows: AppDataUsage[] = [];
for (const [key, row] of desiredByKey) {
if (existingByKey.has(key)) {
continue;
}
const newRow = await context.app.createAppDataUsageAsync({
appDataUsageCategory: row.category ?? undefined,
appDataUsageProtection: row.protection ?? undefined,
appDataUsagePurpose: row.purpose ?? undefined,
});
createdRows.push(newRow);
created += 1;
}
if (created === 0 && deleted === 0) {
Log.log(chalk`{dim - Skipped data usage, no changes}`);
} else {
Log.log(
chalk`{dim - Synced data usage: ${String(created)} created, ${String(deleted)} deleted}`
);
// Refresh in-memory state so subsequent invocations see the new rows.
context.dataUsages = [
...existingRows.filter(row => {
const key = rowKey(
(row.attributes.category?.id as AppDataUsageCategoryId | undefined) ?? null,
(row.attributes.purpose?.id as AppDataUsagePurposeId | undefined) ?? null,
(row.attributes.dataProtection?.id as AppDataUsageDataProtectionId | undefined) ?? null
);
return desiredByKey.has(key);
}),
...createdRows,
];
}
// Apple gates the Privacy Nutrition Labels behind a separate publish
// state. Without flipping `published: true` after a write, the rows above
// won't take effect on the storefront. The publish state model is keyed
// by the app id and there's only ever one — refetch it after the writes
// in case it's drifted.
const publishState =
context.dataUsagesPublishState ?? (await context.app.getAppDataUsagesPublishStateAsync())[0];
if (!publishState) {
Log.warn(
chalk`{yellow Could not load data usage publish state — changes may not appear on the App Store. Publish manually from App Store Connect.}`
);
return;
}
context.dataUsagesPublishState = await logAsync(
() => publishState.updateAsync({ published: true }),
{
pending: 'Publishing data usage (privacy nutrition labels)...',
success: 'Published data usage (privacy nutrition labels)',
failure: 'Failed publishing data usage (privacy nutrition labels)',
}
);
}
}
/**
* Build a deterministic key for a (category, purpose, protection) tuple.
* Nulls are folded in so that rows with missing relationships still diff
* correctly against the desired state.
*/
function rowKey(
category: string | null | undefined,
purpose: string | null | undefined,
protection: string | null | undefined
): string {
return `${category ?? ''}::${purpose ?? ''}::${protection ?? ''}`;
}
/**
* Expand a config-shaped {@link AppleDataUsage} into the flat list of
* (category, purpose, protection) tuples that App Store Connect actually
* stores. When `dataNotCollected` is true we emit a single sentinel row that
* declares the app collects no data; this matches Apple's "Data Not Collected"
* toggle, which is just a special row with no category/purpose/protection.
*/
function expandDataUsageRows(dataUsage: AppleDataUsage): {
category: AppDataUsageCategoryId | null;
purpose: AppDataUsagePurposeId | null;
protection: AppDataUsageDataProtectionId | null;
}[] {
if (dataUsage.dataNotCollected) {
return [{ category: null, purpose: null, protection: null }];
}
const rows: {
category: AppDataUsageCategoryId | null;
purpose: AppDataUsagePurposeId | null;
protection: AppDataUsageDataProtectionId | null;
}[] = [];
for (const entry of dataUsage.categories ?? []) {
const purposes = entry.purposes && entry.purposes.length > 0 ? entry.purposes : [null];
const protections =
entry.protections && entry.protections.length > 0 ? entry.protections : [null];
for (const purpose of purposes) {
for (const protection of protections) {
rows.push({
category: entry.category as AppDataUsageCategoryId,
purpose: (purpose as AppDataUsagePurposeId | null) ?? null,
protection: (protection as AppDataUsageDataProtectionId | null) ?? null,
});
}
}
}
return rows;
}
/**
* Collapse the flat list of {@link AppDataUsage} rows from App Store Connect
* back into the grouped {@link AppleDataUsage} schema we serialize to the
* config. Rows are grouped by category; purposes and protections within a
* category are deduped. Returns `null` when there are no rows.
*/
function collapseDataUsageRows(rows: AppDataUsage[]): AppleDataUsage | null {
if (rows.length === 0) {
return null;
}
const byCategory = new Map<string, { purposes: Set<string>; protections: Set<string> }>();
let dataNotCollected = false;
for (const row of rows) {
const categoryId = row.attributes.category?.id;
const purposeId = row.attributes.purpose?.id;
const protectionId = row.attributes.dataProtection?.id;
// A row with no category/purpose/protection at all is the "Data Not
// Collected" sentinel. We don't surface its DATA_NOT_COLLECTED protection
// either — when present we just flip the top-level toggle.
if (!categoryId && !purposeId && protectionId === 'DATA_NOT_COLLECTED') {
dataNotCollected = true;
continue;
}
if (!categoryId) {
continue;
}
let bucket = byCategory.get(categoryId);
if (!bucket) {
bucket = { purposes: new Set(), protections: new Set() };
byCategory.set(categoryId, bucket);
}
if (purposeId) {
bucket.purposes.add(purposeId);
}
if (protectionId) {
bucket.protections.add(protectionId);
}
}
if (dataNotCollected && byCategory.size === 0) {
return { dataNotCollected: true };
}
if (byCategory.size === 0) {
return null;
}
const categories: AppleDataUsageCategoryEntry[] = [];
for (const [category, { purposes, protections }] of byCategory) {
categories.push({
category: category as AppleDataUsageCategoryIdString,
purposes:
purposes.size > 0 ? (Array.from(purposes) as AppleDataUsagePurposeIdString[]) : undefined,
protections:
protections.size > 0
? (Array.from(protections) as AppleDataUsageDataProtectionIdString[])
: undefined,
});
}
// Stable category ordering keeps the generated config diff-friendly.
categories.sort((a, b) => a.category.localeCompare(b.category));
return { categories };
}
function isNotFoundError(error: any): boolean {
if (!error) {
return false;
}
const status = error?.response?.status ?? error?.status;
if (status === 404) {
return true;
}
const message = String(error?.message ?? '');
return /not.?found|404/i.test(message);
}
@@ -3,7 +3,6 @@ import { AppClipTask } from './app-clip';
import { AppInfoTask } from './app-info';
import { AppReviewDetailTask } from './app-review-detail';
import { AppVersionOptions, AppVersionTask } from './app-version';
import { DataUsageTask } from './data-usage';
import { PreviewsTask } from './previews';
import { ScreenshotsTask } from './screenshots';
import { AppleTask } from '../task';
@@ -24,6 +23,5 @@ export function createAppleTasks({ version }: AppleTaskOptions = {}): AppleTask[
new ScreenshotsTask(),
new PreviewsTask(),
new AppClipTask(),
new DataUsageTask(),
];
}
+6 -62
View File
@@ -1,9 +1,6 @@
import type {
AgeRatingDeclarationProps,
AppClipAction,
AppDataUsageCategoryId,
AppDataUsageDataProtectionId,
AppDataUsagePurposeId,
PreviewType,
ScreenshotDisplayType,
} from '@expo/apple-utils';
@@ -57,64 +54,6 @@ export interface AppleMetadata {
review?: AppleReview;
/** App Clip metadata. Only applies to apps that ship an App Clip target. */
appClip?: AppleAppClip;
/** Privacy metadata, including App Privacy Nutrition Labels (data usage). */
privacy?: ApplePrivacy;
}
/** Top-level privacy block. Currently only contains data usage (Privacy Nutrition Labels). */
export interface ApplePrivacy {
/**
* Privacy Nutrition Labels (App Privacy details). Required for new app
* submissions since 2021. When set, the local config is treated as the
* source of truth: existing data usage entries on App Store Connect are
* replaced to match, then published.
*/
dataUsage?: AppleDataUsage;
}
/** Data usage category enum (e.g. CONTACTS, PRECISE_LOCATION). */
export type AppleDataUsageCategoryId = `${AppDataUsageCategoryId}`;
/** Data usage purpose enum (e.g. ANALYTICS, APP_FUNCTIONALITY). */
export type AppleDataUsagePurposeId = `${AppDataUsagePurposeId}`;
/** Data protection / linkage enum (e.g. DATA_LINKED_TO_YOU). */
export type AppleDataUsageDataProtectionId = `${AppDataUsageDataProtectionId}`;
/**
* Privacy Nutrition Labels — declarative declaration of data collected by the
* app. When `dataNotCollected` is true, no data is collected and `categories`
* must be omitted (or empty).
*/
export interface AppleDataUsage {
/**
* Whether the app collects no data at all. When set, `categories` must be
* empty. Mirrors Apple's "Data Not Collected" toggle in App Store Connect.
*/
dataNotCollected?: boolean;
/** Per-category declarations. Order is not significant. */
categories?: AppleDataUsageCategoryEntry[];
}
/**
* One declaration row in the App Privacy details. Each row pairs a data
* category with the purposes it's used for and the protection / linkage
* applied to it.
*/
export interface AppleDataUsageCategoryEntry {
/** Apple data category, e.g. `CONTACTS`, `PRECISE_LOCATION`. */
category: AppleDataUsageCategoryId;
/**
* Purposes the category is used for, e.g. `ANALYTICS`, `APP_FUNCTIONALITY`.
* At least one entry is required when the category is collected.
*/
purposes?: AppleDataUsagePurposeId[];
/**
* Data protection / linkage classification, e.g. `DATA_LINKED_TO_YOU`,
* `DATA_USED_TO_TRACK_YOU`. Multiple values are allowed: a single category
* can be both linked to the user and used for tracking.
*/
protections?: AppleDataUsageDataProtectionId[];
}
/** App Clip action enum values from App Store Connect API */
@@ -189,5 +128,10 @@ export interface AppleReview {
demoPassword?: string;
demoRequired?: boolean;
notes?: string;
// attachment?: string;
/**
* Relative path (from project root) to a file shared with App Review.
* This is uploaded as an `appStoreReviewAttachment` and re-uploaded only when the
* file's checksum differs from the existing attachment.
*/
attachment?: string;
}