Store compliance — PrivacyInfo.xcprivacy and Play Data safety from declarations

voltro compliance generate folds every installed plugin's privacy declaration with the app's own into Apple's PrivacyInfo.xcprivacy and the Play Data-safety answers; plugins that move data and declare nothing are listed.

Apple asks every app for a privacy manifest (PrivacyInfo.xcprivacy) and Google for the Data safety form. Both are answers to one question — what does this app collect, is it linked to a person, is it used for tracking, and why — and the honest source of those answers is the code that collects: the plugins. So each plugin declares it, the app adds its own, and voltro compliance generate folds them:

// a plugin's definePlugin({ … })
privacy: {
  collects: [{ category: 'identifiers', linked: true, tracking: false, purposes: ['app-functionality'] }],
  requiredReasonApis: [{ api: 'NSPrivacyAccessedAPICategoryUserDefaults', reasons: ['CA92.1'] }],
}

// app.config.ts of the api — the app's own answers on top
privacy: {
  collects: [{ category: 'user-content', linked: false, tracking: false, purposes: ['app-functionality'] }],
  tracking: false,
}
voltro compliance generate apps/acme/api            # → apps/acme/api/.framework/compliance/
voltro compliance generate apps/acme/api --strict   # exit 1 when a data-moving plugin declares nothing

The output directory holds PrivacyInfo.xcprivacy (the plist Apple reads — copy it into the iOS project, or point an Expo config plugin at it) and play-data-safety.json (the Data-safety answers per data type: collected, shared, optional, purposes — Google has no import format, so this is what a human enters in the Play Console, kept in the repo and reviewed like code). Categories use one vocabulary (identifiers, email, device-id, precise-location, crash-data, …) mapped to Apple's NSPrivacyCollectedDataType… identifiers and Google's data-type groups; purposes likewise. Rows from several plugins for one category are merged: linked and tracking OR-ed, purposes unioned, and every row names who declared it.

auth (identifiers, email), notifications (device ids, identifiers, delivery diagnostics) and releases (the install id, unlinked) ship declarations. A plugin with routes or tables and no privacy is listed under undeclared — a finding, and with --strict a refusal, because a missing answer is not "collects nothing". This is the framework half of the managed deploy pipeline and works self-hosted on its own.

Egress — where data leaves the installation

The same command writes egress.json: every place the installation sends data outside itself, with what leaves and how to turn it off — the list an operator reviews before opening a firewall, or answers "does this run air-gapped" from.

// a plugin's definePlugin({ … })
egress: [{
  destination: 's3.eu-central-1.amazonaws.com',   // a host or URL, or 'runtime:<what decides it>'
  purpose: 'stored files and their reads',
  data: ['files'],                                // content | personal | files | metadata | telemetry | credentials
  disable: "provider 'database' keeps the bytes inside the installation",
}]
// app.config.ts of the api — the app's own outbound calls on top
egress: [{ destination: 'hooks.example-host.com', purpose: 'build hook', data: ['metadata'], disable: 'remove the hook' }]

Rows for one destination and purpose are merged (what leaves unioned) and name who declared them. storage declares the object store it is configured for (s3, MinIO, Azure) and nothing on database, filesystem or memory. webhooks declares runtime:registered webhook targets — the addresses are data, not configuration — and the command adds that row whenever the app emits webhooks, because delivery is wired by file discovery with or without webhooksPlugin(). mail declares its provider's API host (api.lettermint.co, api.resend.com, email.<region>.amazonaws.com, …) or the SMTP server's host — never the credentials an SMTP URL carries — and nothing on console or memory. billing declares api.stripe.com on the stripe provider and nothing on the mock. An empty list means none is declared; a plugin that calls out and declares nothing is not caught here.