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
10 changed files with 917 additions and 543 deletions

No files matched your search

+109 -33
View File
@@ -800,39 +800,118 @@
"vi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Vietnamese" }
}
},
"AppleInAppPurchase": {
"ApplePrivacy": {
"type": "object",
"additionalProperties": false,
"description": "Basic listing of an In-App Purchase. Localizations, pricing, and review screenshots are intentionally out of scope for this schema iteration.",
"required": ["productId", "referenceName", "type"],
"description": "Privacy metadata. Currently only contains App Privacy Nutrition Labels (data usage).",
"properties": {
"productId": {
"type": "string",
"description": "Unique product identifier for the IAP. Acts as the primary key when reconciling against App Store Connect.",
"minLength": 1
},
"referenceName": {
"type": "string",
"description": "Internal reference name shown in App Store Connect.",
"minLength": 1
},
"type": {
"enum": [
"CONSUMABLE",
"NON_CONSUMABLE",
"NON_RENEWING_SUBSCRIPTION",
"AUTO_RENEWABLE_SUBSCRIPTION",
"AUTOMATICALLY_RENEWABLE_SUBSCRIPTION",
"FREE_SUBSCRIPTION"
],
"description": "In-App Purchase type."
},
"state": {
"type": "string",
"description": "Read-only review state from App Store Connect. Only populated by `metadata:pull`; ignored on `metadata:push`."
"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",
@@ -1068,12 +1147,9 @@
"$ref": "#/definitions/apple/AppleAppClip",
"description": "App Clip metadata. Only applies to apps that ship an App Clip target."
},
"inAppPurchases": {
"type": "array",
"description": "Basic listing of In-App Purchases. Currently limited to a declarative round-trip of productId / referenceName / type. Pull is supported; push only reports diffs because the underlying apple-utils API is read-only for now.",
"items": {
"$ref": "#/definitions/apple/AppleInAppPurchase"
}
"privacy": {
"$ref": "#/definitions/apple/ApplePrivacy",
"description": "Privacy metadata, including App Privacy Nutrition Labels (data usage). Required for new app submissions since 2021."
}
}
}
@@ -20,7 +20,7 @@ import {
AppleAppClip,
AppleAppClipDefaultExperience,
AppleAppClipLocalizedInfo,
AppleInAppPurchase,
AppleDataUsage,
AppleMetadata,
ApplePreviews,
AppleScreenshots,
@@ -260,12 +260,10 @@ export class AppleConfigReader {
}
/**
* Get the desired In-App Purchase listing from config, or null if the
* key is missing entirely. An empty array is preserved as an empty array
* (the user explicitly cleared their list) so callers can distinguish
* "unconfigured" from "intentionally empty".
* 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 getInAppPurchases(): AppleInAppPurchase[] | null {
return this.schema.inAppPurchases ?? null;
public getDataUsage(): AppleDataUsage | null {
return this.schema.privacy?.dataUsage ?? null;
}
}
@@ -17,7 +17,7 @@ import {
AppleAppClipDefaultExperience,
AppleAppClipLocalizedInfo,
AppleAppClipReviewDetail,
AppleInAppPurchase,
AppleDataUsage,
AppleMetadata,
ApplePreviews,
AppleScreenshots,
@@ -219,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'>
@@ -242,25 +260,6 @@ export class AppleConfigWriter {
}
}
/**
* Set the In-App Purchase listing. Sorts by `productId` for stable
* `metadata:pull` round-trips and drops the key entirely when there are
* no IAPs to avoid spamming `store.config.json` with empty arrays.
*/
public setInAppPurchases(entries: AppleInAppPurchase[]): void {
if (!entries || entries.length === 0) {
delete this.schema.inAppPurchases;
return;
}
const sorted = [...entries].sort((a, b) => a.productId.localeCompare(b.productId));
this.schema.inAppPurchases = sorted.map(entry => ({
productId: entry.productId,
referenceName: entry.referenceName,
type: entry.type,
...(entry.state ? { state: entry.state } : {}),
}));
}
/** Set per-locale App Clip info (subtitle + header image). */
public setAppClipLocalizedInfo(locale: string, info: AppleAppClipLocalizedInfo): void {
this.schema.appClip = this.schema.appClip ?? {};
+2 -2
View File
@@ -5,7 +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 { InAppPurchasesData } from './tasks/in-app-purchases';
import type { DataUsageData } from './tasks/data-usage';
import type { PreviewsData } from './tasks/previews';
import type { ScreenshotsData } from './tasks/screenshots';
@@ -20,7 +20,7 @@ export type AppleData = { app: App; projectDir: string } & AppInfoData &
ScreenshotsData &
PreviewsData &
AppClipData &
InAppPurchasesData;
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();
}
});
});
});
@@ -1,284 +0,0 @@
import { App, InAppPurchase, InAppPurchaseState, InAppPurchaseType } from '@expo/apple-utils';
import nock from 'nock';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { AppleData, PartialAppleData } from '../../data';
import { InAppPurchasesTask } from '../in-app-purchases';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
function makeIap(
id: string,
attrs: {
productId: string;
referenceName: string;
inAppPurchaseType?: InAppPurchaseType;
state?: InAppPurchaseState;
}
): InAppPurchase {
return new InAppPurchase(requestContext, id, {
productId: attrs.productId,
referenceName: attrs.referenceName,
inAppPurchaseType: attrs.inAppPurchaseType ?? InAppPurchaseType.NON_CONSUMABLE,
state: attrs.state ?? InAppPurchaseState.APPROVED,
} as any);
}
const IAPS_EMPTY_RESPONSE = {
data: [],
links: {
self: 'https://appstoreconnect.apple.com/iris/v1/apps/stub-id/inAppPurchases',
},
meta: { paging: { total: 0, limit: 50 } },
};
describe(InAppPurchasesTask, () => {
afterEach(() => {
nock.cleanAll();
});
describe('prepareAsync', () => {
it('initializes an empty map when the app has no IAPs', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(/\/apps\/stub-id\/inAppPurchases/)
.reply(200, IAPS_EMPTY_RESPONSE);
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new InAppPurchasesTask().prepareAsync({ context });
expect(context.inAppPurchases).toBeDefined();
expect(context.inAppPurchases?.size).toBe(0);
expect(scope.isDone()).toBeTruthy();
});
it('warns and falls back to empty map when ASC errors out', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(/\/apps\/stub-id\/inAppPurchases/)
.reply(500, { errors: [{ status: '500', title: 'boom' }] });
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new InAppPurchasesTask().prepareAsync({ context });
expect(context.inAppPurchases?.size).toBe(0);
expect(scope.isDone()).toBeTruthy();
});
});
describe('downloadAsync', () => {
it('writes nothing when the inventory is empty', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new InAppPurchasesTask().downloadAsync({
config: writer,
context: { inAppPurchases: new Map() } as AppleData,
});
expect(writer.setInAppPurchases).toBeCalledWith([]);
});
it('writes a single IAP entry when one is registered', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const iap = makeIap('iap-1', {
productId: 'com.example.coins',
referenceName: 'Coins',
inAppPurchaseType: InAppPurchaseType.CONSUMABLE,
state: InAppPurchaseState.APPROVED,
});
await new InAppPurchasesTask().downloadAsync({
config: writer,
context: {
inAppPurchases: new Map([[iap.attributes.productId, iap]]),
} as AppleData,
});
expect(writer.setInAppPurchases).toBeCalledWith([
{
productId: 'com.example.coins',
referenceName: 'Coins',
type: 'CONSUMABLE',
state: 'APPROVED',
},
]);
});
it('writes multiple IAP entries when several are registered', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const a = makeIap('iap-a', {
productId: 'com.example.a',
referenceName: 'A',
inAppPurchaseType: InAppPurchaseType.CONSUMABLE,
});
const b = makeIap('iap-b', {
productId: 'com.example.b',
referenceName: 'B',
inAppPurchaseType: InAppPurchaseType.NON_CONSUMABLE,
});
await new InAppPurchasesTask().downloadAsync({
config: writer,
context: {
inAppPurchases: new Map([
['com.example.a', a],
['com.example.b', b],
]),
} as AppleData,
});
expect(writer.setInAppPurchases).toBeCalledTimes(1);
const arg = writer.setInAppPurchases.mock.calls[0][0];
expect(arg).toHaveLength(2);
expect(arg.map((entry: any) => entry.productId).sort()).toEqual([
'com.example.a',
'com.example.b',
]);
});
});
describe('uploadAsync', () => {
it('skips when no IAPs are configured', async () => {
const reader = new AppleConfigReader({});
await new InAppPurchasesTask().uploadAsync({
config: reader,
context: { inAppPurchases: new Map() } as AppleData,
});
// No HTTP calls should occur — the task is read-only.
expect(nock.pendingMocks()).toHaveLength(0);
});
it('treats an existing matching IAP as a no-op', async () => {
const existing = makeIap('iap-1', {
productId: 'com.example.coins',
referenceName: 'Coins',
inAppPurchaseType: InAppPurchaseType.CONSUMABLE,
});
const reader = new AppleConfigReader({
inAppPurchases: [
{
productId: 'com.example.coins',
referenceName: 'Coins',
type: 'CONSUMABLE',
},
],
});
await new InAppPurchasesTask().uploadAsync({
config: reader,
context: {
inAppPurchases: new Map([['com.example.coins', existing]]),
} as AppleData,
});
// Read-only push: no HTTP calls regardless of state.
expect(nock.pendingMocks()).toHaveLength(0);
});
it('reports a would-create when config has a new IAP', async () => {
const reader = new AppleConfigReader({
inAppPurchases: [
{
productId: 'com.example.new',
referenceName: 'New IAP',
type: 'NON_CONSUMABLE',
},
],
});
await new InAppPurchasesTask().uploadAsync({
config: reader,
context: { inAppPurchases: new Map() } as AppleData,
});
// No HTTP calls — current implementation only warns.
expect(nock.pendingMocks()).toHaveLength(0);
});
it('reports a would-rename when referenceName differs', async () => {
const existing = makeIap('iap-1', {
productId: 'com.example.coins',
referenceName: 'Coins',
inAppPurchaseType: InAppPurchaseType.CONSUMABLE,
});
const reader = new AppleConfigReader({
inAppPurchases: [
{
productId: 'com.example.coins',
referenceName: 'Gold Coins',
type: 'CONSUMABLE',
},
],
});
await new InAppPurchasesTask().uploadAsync({
config: reader,
context: {
inAppPurchases: new Map([['com.example.coins', existing]]),
} as AppleData,
});
// No HTTP calls — current implementation only warns.
expect(nock.pendingMocks()).toHaveLength(0);
});
});
describe('round-trip', () => {
it('preserves productId / referenceName / type from pull to push', async () => {
// Pull: fetch from ASC and write to a real (un-mocked) writer.
jest.unmock('../../config/writer');
const { AppleConfigWriter: RealWriter } = jest.requireActual(
'../../config/writer'
);
const writer = new RealWriter();
const iap = makeIap('iap-1', {
productId: 'com.example.gem',
referenceName: 'Gem',
inAppPurchaseType: InAppPurchaseType.NON_CONSUMABLE,
state: InAppPurchaseState.APPROVED,
});
await new InAppPurchasesTask().downloadAsync({
config: writer,
context: {
inAppPurchases: new Map([[iap.attributes.productId, iap]]),
} as AppleData,
});
const schema = writer.toSchema();
expect(schema.apple.inAppPurchases).toEqual([
{
productId: 'com.example.gem',
referenceName: 'Gem',
type: 'NON_CONSUMABLE',
state: 'APPROVED',
},
]);
// Push: feed the schema back through the reader and confirm the
// existing IAP is treated as already in sync (no warnings, no calls).
const reader = new AppleConfigReader(schema.apple);
await new InAppPurchasesTask().uploadAsync({
config: reader,
context: {
inAppPurchases: new Map([[iap.attributes.productId, iap]]),
} as AppleData,
});
expect(nock.pendingMocks()).toHaveLength(0);
});
});
});
@@ -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);
}
@@ -1,151 +0,0 @@
import { InAppPurchase } from '@expo/apple-utils';
import chalk from 'chalk';
import Log from '../../../log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
// TODO(expo/third-party#148): Once @expo/apple-utils is bumped to include the
// InAppPurchaseV2 and InAppPurchaseLocalization models, switch from v1
// read-only to v2 CRUD:
// - Use App.getInAppPurchasesV2Async() for listing instead of getInAppPurchasesAsync()
// - Use InAppPurchaseV2.createAsync() for creation
// - Use InAppPurchaseV2.updateAsync() for referenceName changes
// - Use InAppPurchaseLocalization for localized name/description round-trip
// (schema should add an optional `localizations` array per IAP entry)
//
// Note: auto-renewable subscriptions are a separate Apple resource
// (`subscriptionGroups` / `subscriptions`) and are intentionally out of scope
// for this task. The v2 InAppPurchaseV2Type enum only has CONSUMABLE,
// NON_CONSUMABLE, and NON_RENEWING_SUBSCRIPTION.
export type InAppPurchasesData = {
/**
* The list of in-app purchases registered for the app, keyed by `productId`.
* The map is empty when the app has no IAPs (or none are visible to the
* authenticated account).
*/
inAppPurchases: Map<string, InAppPurchase>;
};
/**
* Task for managing the basic listing of In-App Purchases (declarative
* round-trip of `productId`, `referenceName`, and `inAppPurchaseType`).
*
* Scope (intentional, see PR description):
* - Pull (download): writes the existing IAPs to `store.config.json`.
* - Push (upload): no-op for now. `@expo/apple-utils` only exposes
* read access to the deprecated v1 `inAppPurchases` resource — there is
* no create/update/delete and no v2 (`inAppPurchasesV2`) wrapper yet.
* When the user has IAPs in their config that don't exist (or differ) in
* ASC, we emit a warning so the round-trip is still informative without
* silently dropping intent.
*
* Explicitly out of scope for this iteration:
* - Localizations (no `InAppPurchaseLocalization` model in apple-utils)
* - Pricing, review screenshots, content hosting, family sharing
* - Deletes — we never delete IAPs that exist in ASC but are missing from
* config. Too dangerous for a first iteration.
*/
export class InAppPurchasesTask extends AppleTask {
public name = (): string => 'in-app purchases';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
context.inAppPurchases = new Map();
try {
const purchases = await context.app.getInAppPurchasesAsync();
for (const purchase of purchases) {
const productId = purchase.attributes.productId;
if (productId) {
context.inAppPurchases.set(productId, purchase);
}
}
} catch (error: any) {
// The IAP endpoint is on the iris API and may be unavailable for some
// accounts/apps (e.g. apps that have never had an IAP, or accounts
// without the right entitlements). Treat as empty rather than fatal.
Log.warn(
chalk`{yellow Skipped in-app purchases - failed to load from App Store Connect: ${error?.message ?? error}}`
);
}
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
const entries = Array.from(context.inAppPurchases.values()).map(purchase => ({
productId: purchase.attributes.productId,
referenceName: purchase.attributes.referenceName,
type: purchase.attributes.inAppPurchaseType as unknown as string,
state: (purchase.attributes.state as unknown as string) ?? undefined,
}));
config.setInAppPurchases(entries);
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
const desired = config.getInAppPurchases();
if (!desired || desired.length === 0) {
Log.log(chalk`{dim - Skipped in-app purchases, none configured}`);
return;
}
// Match desired entries against the existing ASC inventory by productId.
// We never delete; we only report what would change. Apple's v1
// inAppPurchases endpoint exposed by @expo/apple-utils is read-only,
// so we can't actually create or patch records here.
const existing = context.inAppPurchases;
const toCreate: typeof desired = [];
const toUpdate: { productId: string; from: string; to: string }[] = [];
let unchanged = 0;
for (const entry of desired) {
const current = existing.get(entry.productId);
if (!current) {
toCreate.push(entry);
continue;
}
if (
entry.referenceName &&
current.attributes.referenceName !== entry.referenceName
) {
toUpdate.push({
productId: entry.productId,
from: current.attributes.referenceName,
to: entry.referenceName,
});
} else {
unchanged++;
}
}
if (unchanged > 0) {
Log.log(
chalk`{dim - In-app purchases: ${unchanged} already in sync}`
);
}
if (toCreate.length === 0 && toUpdate.length === 0) {
return;
}
// We intentionally only warn here. See class doc-comment.
Log.warn(
chalk`{yellow In-app purchase mutations are not yet supported by EAS metadata.}`
);
Log.warn(
chalk`{yellow IAP mutations require @expo/apple-utils with InAppPurchaseV2 support (see expo/third-party#148).}`
);
for (const entry of toCreate) {
Log.warn(
chalk` {yellow Would create IAP ${chalk.bold(entry.productId)} (${entry.type}) — ${entry.referenceName}}`
);
}
for (const entry of toUpdate) {
Log.warn(
chalk` {yellow Would rename IAP ${chalk.bold(entry.productId)}: ${entry.from} → ${entry.to}}`
);
}
Log.warn(
chalk`{yellow Apply these changes manually in App Store Connect for now.}`
);
}
}
@@ -3,7 +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 { InAppPurchasesTask } from './in-app-purchases';
import { DataUsageTask } from './data-usage';
import { PreviewsTask } from './previews';
import { ScreenshotsTask } from './screenshots';
import { AppleTask } from '../task';
@@ -24,6 +24,6 @@ export function createAppleTasks({ version }: AppleTaskOptions = {}): AppleTask[
new ScreenshotsTask(),
new PreviewsTask(),
new AppClipTask(),
new InAppPurchasesTask(),
new DataUsageTask(),
];
}
+55 -44
View File
@@ -1,6 +1,9 @@
import type {
AgeRatingDeclarationProps,
AppClipAction,
AppDataUsageCategoryId,
AppDataUsageDataProtectionId,
AppDataUsagePurposeId,
PreviewType,
ScreenshotDisplayType,
} from '@expo/apple-utils';
@@ -54,56 +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 {
/**
* In-App Purchase listing. Currently limited to a declarative round-trip
* of `productId`, `referenceName`, and `type`. Localizations, pricing,
* and review screenshots are intentionally out of scope for now.
*
* Note: auto-renewable subscriptions are a separate Apple resource
* (`subscriptionGroups` / `subscriptions`) and are intentionally out of
* scope for this task. They may appear here as legacy v1 entries on pull
* but cannot be created/managed via IAP APIs.
*
* TODO: Once apple-utils is bumped with InAppPurchaseLocalization
* (expo/third-party#148), add an optional `localizations` array per IAP
* entry for localized name/description round-trip.
* 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.
*/
inAppPurchases?: AppleInAppPurchase[];
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[];
}
/**
* In-App Purchase type values from App Store Connect API.
*
* The v2 `inAppPurchasesV2` resource only supports CONSUMABLE, NON_CONSUMABLE,
* and NON_RENEWING_SUBSCRIPTION. Auto-renewable subscriptions are a completely
* separate Apple resource (`subscriptionGroups` / `subscriptions`) and are NOT
* part of the IAP v2 API. See expo/third-party#148 for details.
*
* The legacy v1 types are preserved so that `metadata:pull` can still surface
* existing auto-renewable and free subscription IAPs that were created before
* the v2 split.
* 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 type AppleInAppPurchaseType =
/** v2 IAP types (supported for CRUD once apple-utils is bumped) */
| 'CONSUMABLE'
| 'NON_CONSUMABLE'
| 'NON_RENEWING_SUBSCRIPTION'
/** @deprecated v1 legacy — auto-renewable subscriptions live on `subscriptionGroups`/`subscriptions` in v2 */
| 'AUTO_RENEWABLE_SUBSCRIPTION'
/** @deprecated v1 legacy — same as AUTO_RENEWABLE_SUBSCRIPTION, apple-utils v1 spelling */
| 'AUTOMATICALLY_RENEWABLE_SUBSCRIPTION'
/** @deprecated v1 legacy */
| 'FREE_SUBSCRIPTION';
export interface AppleInAppPurchase {
/** Product identifier (primary key, must be unique within the app). */
productId: string;
/** Internal reference name shown in App Store Connect. */
referenceName: string;
/** IAP type. */
type: AppleInAppPurchaseType | string;
/** Read-only review/state from App Store Connect. Only populated on pull. */
state?: string;
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 */