Compare commits

...
Author SHA1 Message Date
evanbaconandClaude Opus 4.6 9d93c6ed94 feat(metadata): support privacy nutrition labels (data usage)
Adds a new `privacy.dataUsage` block to the eas-cli metadata schema and
a `DataUsageTask` that pushes/pulls App Privacy Nutrition Labels via the
@expo/apple-utils AppDataUsage* models. After a successful push the
publish state is flipped to published so the changes take effect.

Apple has required every new app submission since 2021 to declare what
data the app collects, so this closes the biggest remaining gap in
`eas metadata`.

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

No files matched your search

+116
View File
@@ -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."
}
}
}
@@ -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;
}
}
@@ -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<AppleAppClipDefaultExperience, 'action' | 'releaseWithAppStoreVersion'>
+3 -1
View File
@@ -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.
@@ -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();
}
});
});
});
@@ -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<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,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(),
];
}
@@ -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 */