Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ from the other:
| --- | --- |
| `smbcloud-ascapi-core` | Shared transport: JWT auth, the HTTP client, JSON:API envelopes, error types |
| `smbcloud-ascapi-aso` | App Metadata: apps, app infos, versions, bundle IDs, localizations, screenshots |
| `smbcloud-ascapi-signing` | Code signing: certificates, plus local RSA key pair and CSR generation |
| `smbcloud-ascapi-signing` | Code signing: certificates and provisioning profiles, plus local RSA key pair and CSR generation |
| `smbcloud-ascapi-frontend` | Operations both surfaces share, so the CLI and the MCP server agree by construction |
| `smbcloud-ascapi-mcp` | The MCP contract and stdio server |
| `smbcloud-ascapi-cli` | The `ascapi` binary: clap command tree, plus `--mcp` |
Expand Down
60 changes: 60 additions & 0 deletions crates/aso/src/bundle_id.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ pub enum BundleIdPlatform {
MacOs,
#[serde(rename = "UNIVERSAL")]
Universal,
/// Not an app platform: the identifier kind Apple assigns to Services
/// IDs (Sign in with Apple, push-only identifiers, and similar).
///
/// Present because `GET /v1/bundleIds` returns every identifier the
/// team owns, mixed together. Without this variant, one Services ID
/// anywhere in the account fails the deserialization of the whole
/// list, so listing breaks for reasons that have nothing to do with
/// the app being looked up.
#[serde(rename = "SERVICES")]
Services,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -69,6 +79,11 @@ impl BundleIdsApi for Client {
if let Some(identifier) = filter_identifier {
query.push(("filter[identifier]", identifier));
}
// Apple's default page size is 20. Without this an unfiltered list
// silently stops at the first 20 identifiers, which reads as "that
// bundle ID is not registered" for anything further down.
query.push(("limit", "200"));

let doc: ListDocument<BundleIdAttributes> = self
.request(Method::GET, "/v1/bundleIds", &query, None::<&()>)
.await?;
Expand All @@ -89,3 +104,48 @@ impl BundleIdsApi for Client {
Ok(doc.data)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn a_services_identifier_does_not_break_the_list() {
// Regression: `GET /v1/bundleIds` returns every identifier a team
// owns, and a single Services ID used to fail the whole response's
// deserialization — so `bundle-ids list` reported nothing at all
// for accounts that have one.
let body = serde_json::json!({
"data": [
{
"id": "AAA",
"type": "bundleIds",
"attributes": {
"name": "An app",
"identifier": "xyz.example.app",
"platform": "UNIVERSAL",
"seedId": "TEAMID"
}
},
{
"id": "BBB",
"type": "bundleIds",
"attributes": {
"name": "Sign in with Apple",
"identifier": "xyz.example.service",
"platform": "SERVICES",
"seedId": "TEAMID"
}
}
]
});

let doc: ListDocument<BundleIdAttributes> =
serde_json::from_value(body).expect("a Services identifier must not fail the list");
assert_eq!(doc.data.len(), 2);
assert_eq!(
doc.data[1].attributes.platform,
Some(BundleIdPlatform::Services)
);
}
}
225 changes: 225 additions & 0 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ use smbcloud_ascapi_core::{ApiKey, Client};
use smbcloud_ascapi_signing::certificate::{CertificateCreateAttributes, CertificateType};
use smbcloud_ascapi_signing::csr::generate_certificate_request;
use smbcloud_ascapi_signing::prelude::*;
use smbcloud_ascapi_signing::profile::{
ProfileCreateAttributes, ProfileCreateRelationships, ProfileType,
};
use std::path::PathBuf;

/// Add/update App Store Connect app metadata (apps, app infos, app store
Expand Down Expand Up @@ -103,6 +106,131 @@ enum Command {
#[command(subcommand)]
command: CertificatesCommand,
},
/// Provisioning profiles: what a bundle ID is allowed to claim, and
/// which certificates may sign it. Create a new one after changing an
/// App ID's capabilities — existing profiles never pick the change up.
Profiles {
#[command(subcommand)]
command: ProfilesCommand,
},
}

#[derive(Subcommand)]
enum ProfilesCommand {
/// List the team's profiles, newest expiry last.
///
/// `profileContent` is deliberately not shown; use `download` for the
/// file itself.
List {
/// Only show profiles with this exact name. Apple allows
/// duplicates, so this can still match several.
#[arg(long)]
name: Option<String>,
/// Only show one type.
#[arg(long, value_enum)]
r#type: Option<CliProfileType>,
},
/// Create a profile over a bundle ID and one or more certificates.
///
/// This is the only way to pick up a capability added to the App ID
/// after an existing profile was made: profiles are snapshots, and
/// Apple keeps serving the stale one as ACTIVE until its certificate
/// dies. Apple does not treat an equivalent profile as a duplicate, so
/// repeated runs accumulate profiles rather than replacing one.
Create {
/// Label shown in the developer portal.
#[arg(long)]
name: String,
#[arg(long, value_enum)]
r#type: CliProfileType,
/// Resource id of the bundle ID (from `bundle-ids list`), not the
/// reverse-DNS identifier.
#[arg(long)]
bundle_id: String,
/// Certificate resource id (from `certificates list`). Repeat for
/// several.
#[arg(long = "certificate", required = true)]
certificates: Vec<String>,
/// Device resource id. Only meaningful for development and ad hoc
/// types; ignored for store profiles, which Apple rejects if sent
/// devices.
#[arg(long = "device")]
devices: Vec<String>,
},
/// Write a profile's bytes to a file.
///
/// The output is the binary `.provisionprofile`/`.mobileprovision` a
/// bundle embeds, not JSON.
Download {
id: String,
/// Where to write the profile.
#[arg(long)]
output: PathBuf,
},
/// Delete a profile.
///
/// Narrower than revoking a certificate: builds already signed with it
/// keep working, but new signing against it fails.
Delete {
id: String,
/// Required. Pass the profile id again to confirm.
#[arg(long)]
confirm: String,
},
}

#[derive(Copy, Clone, Debug, ValueEnum)]
enum CliProfileType {
/// iOS development — device-scoped.
IosAppDevelopment,
/// iOS App Store distribution.
IosAppStore,
/// iOS ad hoc — device-scoped.
IosAppAdhoc,
/// iOS in-house (Enterprise) distribution.
IosAppInhouse,
/// macOS development — device-scoped.
MacAppDevelopment,
/// Mac App Store distribution — the embedded.provisionprofile a MAS
/// submission carries.
MacAppStore,
/// Developer ID — distribution outside the App Store.
MacAppDirect,
/// tvOS development — device-scoped.
TvosAppDevelopment,
/// tvOS App Store distribution.
TvosAppStore,
/// tvOS ad hoc — device-scoped.
TvosAppAdhoc,
/// tvOS in-house (Enterprise) distribution.
TvosAppInhouse,
/// Mac Catalyst development — device-scoped.
MacCatalystAppDevelopment,
/// Mac Catalyst App Store distribution.
MacCatalystAppStore,
/// Mac Catalyst Developer ID distribution.
MacCatalystAppDirect,
}

impl From<CliProfileType> for ProfileType {
fn from(value: CliProfileType) -> Self {
match value {
CliProfileType::IosAppDevelopment => ProfileType::IosAppDevelopment,
CliProfileType::IosAppStore => ProfileType::IosAppStore,
CliProfileType::IosAppAdhoc => ProfileType::IosAppAdHoc,
CliProfileType::IosAppInhouse => ProfileType::IosAppInHouse,
CliProfileType::MacAppDevelopment => ProfileType::MacAppDevelopment,
CliProfileType::MacAppStore => ProfileType::MacAppStore,
CliProfileType::MacAppDirect => ProfileType::MacAppDirect,
CliProfileType::TvosAppDevelopment => ProfileType::TvOsAppDevelopment,
CliProfileType::TvosAppStore => ProfileType::TvOsAppStore,
CliProfileType::TvosAppAdhoc => ProfileType::TvOsAppAdHoc,
CliProfileType::TvosAppInhouse => ProfileType::TvOsAppInHouse,
CliProfileType::MacCatalystAppDevelopment => ProfileType::MacCatalystAppDevelopment,
CliProfileType::MacCatalystAppStore => ProfileType::MacCatalystAppStore,
CliProfileType::MacCatalystAppDirect => ProfileType::MacCatalystAppDirect,
}
}
}

#[derive(Subcommand)]
Expand Down Expand Up @@ -502,6 +630,7 @@ async fn main() -> Result<()> {
run_app_screenshots(&client, command, cli.dry_run).await
}
Command::Certificates { command } => run_certificates(&client, command, cli.dry_run).await,
Command::Profiles { command } => run_profiles(&client, command, cli.dry_run).await,
}
}

Expand Down Expand Up @@ -1007,6 +1136,102 @@ async fn run_certificates(
}
}

async fn run_profiles(client: &Client, command: ProfilesCommand, dry_run: bool) -> Result<()> {
use smbcloud_ascapi_frontend::profiles::{download_profile, ProfileSummary};
use smbcloud_ascapi_frontend::time::now_iso8601;

match command {
ProfilesCommand::List { name, r#type } => {
let mut profiles = client
.list_profiles(name.as_deref(), r#type.map(Into::into))
.await?;
profiles.sort_by(|a, b| {
a.attributes
.expiration_date
.cmp(&b.attributes.expiration_date)
});
let now = now_iso8601();
let summaries: Vec<_> = profiles
.iter()
.map(|profile| ProfileSummary::from_resource(profile, &now))
.collect();
print_json(&summaries)
}

ProfilesCommand::Create {
name,
r#type,
bundle_id,
certificates,
devices,
} => {
let profile_type: ProfileType = r#type.into();

// Say so rather than dropping them silently: a caller who
// passed devices to a store profile has misunderstood
// something, and the created profile will not be what they
// expect.
if !devices.is_empty() && !profile_type.takes_devices() {
eprintln!(
"note: {} does not take devices; ignoring the {} passed",
profile_type.as_api_str(),
devices.len()
);
}

let relationships =
ProfileCreateRelationships::new(profile_type, &bundle_id, &certificates, &devices);
Comment thread
setoelkahfi marked this conversation as resolved.
let attributes = ProfileCreateAttributes {
name: name.clone(),
profile_type,
};

if dry_run {
print_json(&serde_json::json!({
"data": {
"type": "profiles",
"attributes": attributes,
"relationships": relationships,
}
}))?;
return Ok(());
}

let profile = client.create_profile(attributes, relationships).await?;
print_json(&ProfileSummary::from_resource(&profile, &now_iso8601()))
}

ProfilesCommand::Download { id, output } => {
if dry_run {
println!(
"# dry run — would write profile {id} to {}",
output.display()
);
return Ok(());
}
let downloaded = download_profile(client, &id, &output)
.await
.map_err(anyhow::Error::msg)?;
print_json(&downloaded)
}

ProfilesCommand::Delete { id, confirm } => {
if confirm != id {
anyhow::bail!(
"refusing to delete: pass --confirm {id} to acknowledge that signing \
against this profile stops working until a replacement exists"
);
}
if dry_run {
println!("# dry run — would delete profile {id}");
return Ok(());
}
client.delete_profile(&id).await?;
print_json(&serde_json::json!({ "deleted": id }))
}
}
}

/// Write a private key with owner-only permissions, set at creation time
/// rather than chmod'ed afterwards so the key is never briefly readable.
fn write_private_key(path: &std::path::Path, pem: &str) -> std::io::Result<()> {
Expand Down
13 changes: 13 additions & 0 deletions crates/core/src/jsonapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ pub struct ToOne {
pub data: ResourceId,
}

/// A to-many relationship payload, e.g. `relationships.certificates` on a
/// `POST /v1/profiles` body.
///
/// Apple distinguishes an empty array from an absent relationship: sending
/// `{"data": []}` for `devices` means "no devices", which a development
/// profile rejects, while omitting the key entirely is what an App Store
/// profile wants. Callers that mean "absent" should pass `None` for the
/// whole field rather than an empty `ToMany`.
#[derive(Debug, Clone, Serialize)]
pub struct ToMany {
pub data: Vec<ResourceId>,
}

#[derive(Debug, Clone, Serialize)]
pub struct CreateBody<A, R> {
pub data: CreateData<A, R>,
Expand Down
4 changes: 4 additions & 0 deletions crates/frontend/src/enums.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ pub fn bundle_id_platform_from_str(value: &str) -> Result<BundleIdPlatform, Stri
"ios" => Ok(BundleIdPlatform::Ios),
"mac_os" | "macos" => Ok(BundleIdPlatform::MacOs),
"universal" => Ok(BundleIdPlatform::Universal),
// Accepted so a round trip through this mapper does not lose a
// value the API itself returns. Registering one is a different
// flow than an app bundle ID, and Apple rejects it here.
"services" => Ok(BundleIdPlatform::Services),
other => Err(format!(
"unknown bundle ID platform {other:?}; expected one of ios, mac_os, universal"
)),
Expand Down
5 changes: 5 additions & 0 deletions crates/frontend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,20 @@
//! refactor away from being bypassed.
//! - **No certificate bodies either.** They are large, never needed to
//! decide what to do next, and would otherwise land in a transcript.
//! [`profiles::ProfileSummary`] applies the same rule to
//! `profileContent`, and [`profiles::download_profile`] returns the path
//! it wrote instead.

pub mod certificates;
pub mod enums;
pub mod env;
pub mod profiles;
pub mod time;

pub use certificates::{
certificate_type_from_str, issue_certificate, CertificateSummary, IssuedCertificate,
};
pub use enums::{bundle_id_platform_from_str, display_type_from_str, platform_from_str};
pub use env::api_key_from_env;
pub use profiles::{download_profile, profile_type_from_str, DownloadedProfile, ProfileSummary};
pub use time::{is_expired, now_iso8601};
Loading