Skip to content

feat(cli): adapter-based APNs key setup for init apns --create-key - #1992

Merged
ArnabChatterjee20k merged 17 commits into
messaging-provider-cli-apnsfrom
messaging-provider-cli-apns-adapters
Oct 9, 2026
Merged

ArnabChatterjee20k merged 17 commits into
messaging-provider-cli-apnsfrom
messaging-provider-cli-apns-adapters

Conversation

@ArnabChatterjee20k

@ArnabChatterjee20k ArnabChatterjee20k commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Stacked on #1963 (init apns). This is the base for one-click APNs key creation. The adapters come in separate PRs on top of it:

Why adapters

Apple has no public API for creating APNs keys. spaceship, @expo/apple-utils and our own implementation all sign in as a person would and call the Developer portal's private API, which Apple changes without notice. With adapters, picking the implementation is a setting, not a release: when Apple breaks one, users (or our docs) switch to another.

Usage

From the CLI

# Create the key with the default setup (appwrite), for both environments
appwrite init apns --create-key

# Pick the setup for one run
appwrite init apns --create-key --provider expo

# Or for every run, for example after Apple breaks the default one
export APPWRITE_APNS_SETUP=fastlane
appwrite init apns --create-key

# Answer the sign-in from the environment (CI, or to skip typing)
export APPWRITE_APPLE_ID=dev@example.com
export APPWRITE_APPLE_PASSWORD=...
appwrite init apns --create-key --team-id ABCDE12345

# Only one environment: one provider, and a key for that environment
appwrite init apns --create-key --environment sandbox

# Replace the key on providers that are already set up
appwrite init apns --create-key --force

How the setup is chosen: --provider, then APPWRITE_APNS_SETUP, then appwrite.

Two-factor still needs a person: the code is asked in the terminal. A setup that keeps a session (stored under ~/.appwrite/apns/<setup>/) skips it on later runs.

What happens when key creation fails:

Case Result
--create-key and the sign-in or key creation fails Stops with the reason. Nothing is written to Appwrite
Apple sign-in chosen from the prompt and it fails Warns, then offers the browser and file options
The team already has the maximum number of APNs keys Lists the team's keys and stops: reuse a saved .p8 with --key-path, or revoke one
Unknown --provider / APPWRITE_APNS_SETUP Stops, naming the setups this build has

From Go

Creating a key with a setup:

registry := apns.NewRegistry(appwrite.New(), expo.New())

adapter, err := registry.Get("appwrite")
if err != nil {
    return err
}

key, err := apns.CreateKey(ctx, adapter, apns.Request{
    TeamID:      "ABCDE12345",           // optional: empty lets the sign-in pick or ask
    Name:        "Appwrite Push",
    Environment: apns.EnvironmentAll,    // or EnvironmentProduction / EnvironmentSandbox
    AppleID:     os.Getenv("APPWRITE_APPLE_ID"),       // optional: asked when empty
    Password:    os.Getenv("APPWRITE_APPLE_PASSWORD"), // optional: asked when empty
    SessionDir:  "~/.appwrite/apns/appwrite",
    Asker:       asker, // Ask(question, secret) and Choose(question, options)
    Log:         func(format string, args ...any) { fmt.Printf(format+"\n", args...) },
})

var full *apns.MaxKeysError
switch {
case errors.As(err, &full):
    // full.Keys lists the team's keys
case errors.Is(err, apns.ErrInvalidCredentials):
    // Apple refused the Apple ID or password
case errors.Is(err, apns.ErrUnavailable):
    // the setup cannot run here (for example Node.js missing for expo); nothing was sent to Apple
case err != nil:
    return err
}
// key.KeyID, key.TeamID, key.P8: save key.P8 now, Apple serves it once

Writing a setup:

package mysetup

type Adapter struct{}

func New() *Adapter { return &Adapter{} }

func (*Adapter) Name() string { return "mysetup" }

func (*Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) {
    // 1. Return apns.ErrUnavailable before contacting Apple if this setup cannot run here.
    // 2. Sign in with request.AppleID / request.Password, or ask through request.Asker.
    //    Ask for the two-factor code through request.Asker too. Report progress through request.Log.
    // 3. Use request.TeamID, or pick the account's only team, or ask with request.Asker.Choose.
    // 4. Create a team-scoped key with APNs for request.Environment and download its .p8.
    // 5. Map Apple's errors: a refused sign-in to apns.ErrInvalidCredentials,
    //    a full team to &apns.MaxKeysError{Keys: ...}.
    return apns.Key{KeyID: id, TeamID: team, P8: p8}, nil
}

Then add it in one place, apnsAdapters() in internal/cmd/initpush.go:

func apnsAdapters() *apns.Registry {
    return apns.NewRegistry(appwrite.New(), expo.New(), mysetup.New())
}

apns.CreateKey checks what every setup returns: a 10-character key ID, a valid team ID that matches the requested team, and .p8 contents. A setup that breaks this contract fails loudly instead of writing a bad provider. apnstest.Adapter is a scripted fake for testing code that uses a setup.

What's here

internal/apns (no imports from the CLI, so it can move to its own module and repo):

  • Adapter interface: Name() and CreateKey(ctx, Request) (Key, error).
  • Request: team (optional), key name, environment, optional Apple ID and password, session folder, and the Asker / Log callbacks for prompts and output.
  • Shared results and errors: Key, ExistingKey, MaxKeysError, ErrUnavailable, ErrInvalidCredentials, Environment / ParseEnvironment.
  • CreateKey(ctx, adapter, request) holds every adapter to the same contract. It checks the request (environment, name, team ID format), then the result: a valid key ID and team, the requested team, and .p8 contents.
  • Registry: adapters by name, with clear errors for an unknown name or an empty build.
  • apnstest.Adapter: a scripted fake for tests.

init apns:

  • --create-key creates the key with the chosen setup:
    • --provider appwrite|expo|fastlane, then APPWRITE_APNS_SETUP, then appwrite. The flag + env pattern matches --config-file / APPWRITE_CONFIG_FILE.
    • Sessions are kept per setup under ~/.appwrite/apns/<setup>/.
  • APPWRITE_APPLE_ID / APPWRITE_APPLE_PASSWORD answer the sign-in questions. The CLI prints the variable name, never the value.
  • --environment production|sandbox|all (default all) picks which providers are set up, what counts as "already set up", and the environment of a created key.
  • Without a known team, the sign-in chooses it, and the team question only comes up for key files and the browser flow.
  • The new .p8 is saved (0600, never overwriting) before the providers are written, because Apple allows one download.
  • A full team lists the existing keys and stops with --key-path advice.
  • A failed --create-key stops instead of falling back to a .p8 already in the folder. Choosing the sign-in from the prompt still falls back to the other options.
  • No adapter is registered in this PR, so the sign-in option isn't offered and --create-key says the build has none. Each adapter PR adds itself to apnsAdapters().

Tests

  • internal/apns: environment parsing, request checks (the adapter never runs on a bad request), the result contract (bad key ID, missing team, wrong team), error pass-through, registry lookup and errors.
  • init apns (through the fake adapter):
    • key creation and saving, with no re-creation on a rerun
    • the team coming from the sign-in
    • stopping on failure, and falling back when the sign-in was chosen from the prompt
    • a full team
    • a single environment
    • choosing the setup: --provider beats APPWRITE_APNS_SETUP, which beats the default; credentials passed through without the password ever printed
    • an unknown setup
    • an empty build
  • The full CLI suite (-race), gofmt, Rector and the Generation suite pass.

Supersedes

#1989 and #1991 once the expo and appwrite adapter PRs land on this.

Apple has no public API for creating APNs keys, and the tools that do it
(spaceship, @expo/apple-utils, Appwrite's own) all drive the Developer
portal's private one, which Apple can change at any time. Each becomes an
Adapter behind one contract: CreateKey checks the request, runs the
adapter and checks the key it returns (key ID, team, .p8 contents), so
every setup behaves the same to the caller. A Registry finds them by name.

The package imports nothing from the CLI: prompts, logging, credentials
and the session folder come in through the Request, so it can move to
its own module unchanged. apnstest has a scripted adapter for tests.
--create-key creates the key with the setup picked by --provider, then
APPWRITE_APNS_SETUP, then appwrite, so a setup Apple breaks can be
swapped without a release. APPWRITE_APPLE_ID and APPWRITE_APPLE_PASSWORD
answer the sign-in questions, and the CLI names the variable it used.
--environment (production, sandbox or all) picks the providers to set up
and the environment of a created key. Without a known team, the sign-in
chooses it. The new .p8 is saved before the providers are written, since
Apple allows one download, and a failed --create-key stops rather than
falling back to a key already in the folder.

No setup is registered yet: each comes in its own change, and until then
the sign-in option is not offered.
@hansi-codes

hansi-codes Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

🟢 Tier S · Ready to merge

This PR builds on the shared APNs adapter contract and adds three key-creation backends: Appwrite’s Go-based Apple portal client, an Expo Node helper, and a fastlane lane. It registers those backends in the CLI and adds authentication, session handling, runtime checks, and tests for the adapters and init apns flows.

Latest changes: The newest commits add and register the Appwrite, Expo, and fastlane adapters, including their Apple authentication flows and runtime-specific helpers.

Verdict New comments Fixed Still open
🛑 Changes requested 0 0 0
📂 Walkthrough · 21
File Change
src/SDK/Language/CLI.php Registers the APNs adapter implementations and helper assets in generated CLI output.
templates/cli/internal/apns/apns.go Defines shared APNs adapter contracts, validation, errors, and registry behavior.
templates/cli/internal/apns/apns_test.go Tests environment parsing, request/result validation, and registry behavior.
templates/cli/internal/apns/apnstest/apnstest.go Adds a scripted APNs adapter and test key helper.
templates/cli/internal/apns/appwrite/adapter.go Implements Appwrite's Go-based Apple sign-in and key creation flow.
templates/cli/internal/apns/appwrite/adapter_test.go Exercises Apple sign-in, two-factor, session reuse, team selection, and key creation against a fake portal.
templates/cli/internal/apns/appwrite/auth.go Implements Apple authentication, two-factor verification, and widget-key retrieval.
templates/cli/internal/apns/appwrite/jar.go Persists Apple session cookies for later runs.
templates/cli/internal/apns/appwrite/portal.go Implements Developer portal team/key listing, creation, and key download calls.
templates/cli/internal/apns/appwrite/srp.go Implements Apple's SRP password proof and hashcash challenge.
templates/cli/internal/apns/appwrite/srp_test.go Tests SRP vectors and hashcash behavior.
templates/cli/internal/apns/expo/adapter.go Installs and runs the embedded Expo helper, checking Node availability and decoding results.
templates/cli/internal/apns/expo/adapter_test.go Tests Expo runtime checks, helper invocation, and result decoding.
templates/cli/internal/apns/expo/helper/helper.js Creates APNs keys through @expo/apple-utils and serializes results for the Go adapter.
templates/cli/internal/apns/expo/helper/package.json Declares the Expo helper's Node package and dependency.
templates/cli/internal/apns/expo/helper/package-lock.json Pins the Expo helper dependency in its npm lockfile.
templates/cli/internal/apns/fastlane/adapter.go Runs an embedded fastlane lane and selects the installed or project-bundled fastlane.
templates/cli/internal/apns/fastlane/adapter_test.go Tests fastlane version checks, credential handling, and Bundler selection.
templates/cli/internal/apns/fastlane/helper/Fastfile Creates APNs keys through fastlane's spaceship client and serializes lane results.
templates/cli/internal/cmd/initpush.go Registers the three adapters for init apns setup selection.
templates/cli/internal/cmd/initpush_test.go Tests adapter selection and key-creation behavior in the CLI.

Reviewed the commits since 4cfe2c0 · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · See the inline comments. Summary

Comment thread templates/cli/internal/cmd/initpush.go Outdated
Comment thread templates/cli/internal/cmd/initpush.go Outdated
A key created by the Apple sign-in has an ID Apple chose. --key-id could
override it, and the providers then got an ID that does not match the
.p8, so every push would fail to authenticate. --create-key now rejects
--key-id, and a key created from the prompt always uses Apple's ID,
with a warning when --key-id said otherwise.

Sign-in questions asked without a terminal also no longer suggest
--key-path, which --create-key rejects. The error names the question,
says APPWRITE_APPLE_ID and APPWRITE_APPLE_PASSWORD answer the Apple ID
and password, that the two-factor code always needs a terminal, and to
use --key-path instead of --create-key otherwise.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

The appwrite adapter signs in to Apple and calls the Developer portal in
Go, with nothing to install. The sign-in follows spaceship: the widget
key from the App Store Connect sign out redirect, SRP-6a against
idmsa.apple.com (PBKDF2 over the password, s2k and s2k_fo), hashcash,
two-factor by device code or text message, and a trusted session kept
in the request's session folder so later runs skip two-factor. The key
calls follow @expo/apple-utils: list the team's keys, which hands out
the CSRF tokens, create a team-scoped key with APNs for the requested
environment, and download the .p8.

The SRP and hashcash code is checked against values computed by
fastlane-sirp and spaceship, and the whole flow against a fake Apple
that runs the server side of SRP. The Go bodies of portal.go and
adapter_test.go are Twig verbatim blocks, as their nested composite
literals read as Twig tags.
Registers the appwrite adapter, the default for --create-key, so the
Apple sign-in is offered and --provider appwrite (or
APPWRITE_APNS_SETUP=appwrite) selects it.
The expo adapter creates APNs keys with @expo/apple-utils 2.2.1 (MIT),
the library eas-cli uses. A small Node script, its package.json and a
lockfile pinning the package hash are embedded with go:embed and
installed into the session folder with npm ci --ignore-scripts when they
change. It needs Node.js 18 or later and reports apns.ErrUnavailable
otherwise, before contacting Apple.

apple-utils prompts on the terminal for the two-factor code and for
anything the request leaves out; the request's Apple ID and password
are passed as EXPO_APPLE_ID and EXPO_APPLE_PASSWORD, and
EXPO_NO_KEYCHAIN=1 keeps the password out of the macOS Keychain. Its
InvalidUserCredentialsError maps to apns.ErrInvalidCredentials and a full
team to apns.MaxKeysError. apple-utils always creates keys for both
environments, which also serve a single-environment request.
Registers the expo adapter, picked with --provider expo or
APPWRITE_APNS_SETUP=expo.
The fastlane adapter creates APNs keys with spaceship, fastlane's
open-source Apple Developer portal client, through the fastlane the
person already has installed: a lane in an embedded Fastfile runs in a
private temp folder, so a gem, Homebrew or bundler installation all work
and no Ruby ships with the CLI. It needs fastlane 2.226.0 or later, the
first to sign in with SRP, and reports apns.ErrUnavailable otherwise.

The adapter asks for the Apple ID and password when the request has
none and passes them as FASTLANE_USER and FASTLANE_PASSWORD, so fastlane
never offers to store them in the Keychain; spaceship asks for the
two-factor code on the terminal. The lane calls create_key! directly so
the requested environment is honoured (Key.create always asks for all),
maps InvalidUserCredentialsError to apns.ErrInvalidCredentials and a
full team to apns.MaxKeysError with the team's keys.
Registers the fastlane adapter, picked with --provider fastlane or
APPWRITE_APNS_SETUP=fastlane.
2.225.0 (October 2024) is the release that added SRP sign-in, per its
notes: earlier versions get 503 from Apple. The minimum was set one
release too high.
Typing sms to get the code by text message used one of the three
attempts, leaving two for actual codes. Only codes Apple rejects count
now.
The hashcash date was the machine's local time. @expo/apple-utils sends
UTC (spaceship's local time only matches on a UTC machine), so a
non-UTC machine produced a date hours away from the one Apple expects.
The fastlane setup ran fastlane from PATH in a temporary folder, so a
fastlane installed only through a project's Gemfile was reported as
unavailable. When the Gemfile in the folder the CLI runs in, or in its
ios folder as Flutter and React Native projects keep it, lists fastlane
and bundle is installed, the lane now runs with bundle exec fastlane and
BUNDLE_GEMFILE pointing at that Gemfile. Otherwise fastlane from PATH is
used as before. A Bundler fastlane that does not start asks for bundle
install.
The CLI leaves the team out when none is known and lets the sign-in pick
it. The test now checks that the helper then gets no --team-id.
When the project's Gemfile lists fastlane but Bundler is missing, the
fastlane setup fell back to fastlane from PATH, which can be a different
version from the pinned one. It now reports apns.ErrUnavailable naming
the Gemfile and how to install Bundler.
…g-provider-cli-apns-adapters

# Conflicts:
#	src/SDK/Language/CLI.php
#	templates/cli/internal/cmd/initpush.go
…aging-provider-cli-apns-adapters

# Conflicts:
#	src/SDK/Language/CLI.php
#	templates/cli/internal/cmd/initpush.go
@ArnabChatterjee20k
ArnabChatterjee20k merged commit 53d63b3 into messaging-provider-cli-apns Oct 9, 2026
53 of 61 checks passed

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Tier B · 1 blocking finding to address. Summary

Comment thread templates/cli/internal/apns/expo/adapter.go
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant