diff --git a/packages/eas-cli/schema/metadata-0.json b/packages/eas-cli/schema/metadata-0.json index 0f72528ba1..94f1e7b2a5 100644 --- a/packages/eas-cli/schema/metadata-0.json +++ b/packages/eas-cli/schema/metadata-0.json @@ -800,6 +800,118 @@ "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", @@ -1034,6 +1146,10 @@ "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." } } } diff --git a/packages/eas-cli/src/metadata/apple/config/reader.ts b/packages/eas-cli/src/metadata/apple/config/reader.ts index 0f32395477..cbf30798e5 100644 --- a/packages/eas-cli/src/metadata/apple/config/reader.ts +++ b/packages/eas-cli/src/metadata/apple/config/reader.ts @@ -20,6 +20,7 @@ import { AppleAppClip, AppleAppClipDefaultExperience, AppleAppClipLocalizedInfo, + AppleDataUsage, AppleMetadata, ApplePreviews, AppleScreenshots, @@ -257,4 +258,12 @@ 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; + } } diff --git a/packages/eas-cli/src/metadata/apple/config/writer.ts b/packages/eas-cli/src/metadata/apple/config/writer.ts index 5d444b19ae..fb7a24baaf 100644 --- a/packages/eas-cli/src/metadata/apple/config/writer.ts +++ b/packages/eas-cli/src/metadata/apple/config/writer.ts @@ -17,6 +17,7 @@ import { AppleAppClipDefaultExperience, AppleAppClipLocalizedInfo, AppleAppClipReviewDetail, + AppleDataUsage, AppleMetadata, ApplePreviews, AppleScreenshots, @@ -218,6 +219,24 @@ 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 diff --git a/packages/eas-cli/src/metadata/apple/data.ts b/packages/eas-cli/src/metadata/apple/data.ts index 6806b71448..acb4084b83 100644 --- a/packages/eas-cli/src/metadata/apple/data.ts +++ b/packages/eas-cli/src/metadata/apple/data.ts @@ -5,6 +5,7 @@ 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'; @@ -18,7 +19,8 @@ export type AppleData = { app: App; projectDir: string } & AppInfoData & AppReviewData & ScreenshotsData & PreviewsData & - AppClipData; + AppClipData & + DataUsageData; /** * The unprepared partial apple data, used within the `prepareAsync` tasks. diff --git a/packages/eas-cli/src/metadata/apple/tasks/__tests__/data-usage-test.ts b/packages/eas-cli/src/metadata/apple/tasks/__tests__/data-usage-test.ts new file mode 100644 index 0000000000..6305d64fba --- /dev/null +++ b/packages/eas-cli/src/metadata/apple/tasks/__tests__/data-usage-test.ts @@ -0,0 +1,403 @@ +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(); + } + }); + }); +}); diff --git a/packages/eas-cli/src/metadata/apple/tasks/data-usage.ts b/packages/eas-cli/src/metadata/apple/tasks/data-usage.ts new file mode 100644 index 0000000000..fa498f016a --- /dev/null +++ b/packages/eas-cli/src/metadata/apple/tasks/data-usage.ts @@ -0,0 +1,322 @@ +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 { + 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 { + 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 { + 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(); + 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; protections: Set }>(); + 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); +} diff --git a/packages/eas-cli/src/metadata/apple/tasks/index.ts b/packages/eas-cli/src/metadata/apple/tasks/index.ts index 06fadbdf01..6a7496a45e 100644 --- a/packages/eas-cli/src/metadata/apple/tasks/index.ts +++ b/packages/eas-cli/src/metadata/apple/tasks/index.ts @@ -3,6 +3,7 @@ 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'; @@ -23,5 +24,6 @@ export function createAppleTasks({ version }: AppleTaskOptions = {}): AppleTask[ new ScreenshotsTask(), new PreviewsTask(), new AppClipTask(), + new DataUsageTask(), ]; } diff --git a/packages/eas-cli/src/metadata/apple/types.ts b/packages/eas-cli/src/metadata/apple/types.ts index 75574371df..f3b32c4cce 100644 --- a/packages/eas-cli/src/metadata/apple/types.ts +++ b/packages/eas-cli/src/metadata/apple/types.ts @@ -1,6 +1,9 @@ import type { AgeRatingDeclarationProps, AppClipAction, + AppDataUsageCategoryId, + AppDataUsageDataProtectionId, + AppDataUsagePurposeId, PreviewType, ScreenshotDisplayType, } from '@expo/apple-utils'; @@ -54,6 +57,64 @@ 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 */