setup

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 20, 2026 License: MIT Imports: 29 Imported by: 0

Documentation

Overview

Package setup implements the provider-console orchestration behind `moth setup google`, `moth setup apple` and `moth doctor`: guided or partially automated configuration of the Google/Apple sign-in consoles for one moth project, always followed by verification. The commands are idempotent — they diff the current console/moth state against the desired state and only change what is needed.

Capability spike (milestone 08)

Which steps automate cleanly and which stay guided, as established for this implementation:

Google:

  • Creating OAuth 2.0 client IDs (web/iOS/Android) has NO public API. Neither the Cloud Console credentials API surface nor the IAM "OAuth clients" API (workforce identity only) covers the standard consent-screen clients moth needs; Firebase auto-provisions clients but only inside Firebase-enrolled projects. → GUIDED: the command prints the exact console URL and what to enter, then validates the pasted client ID's shape before accepting it.
  • `gcloud` (when installed and authenticated) verifies that the GCP project exists before sending the user to the console. → automated, best effort; without gcloud the flow is purely guided.
  • Android signing-report fingerprints compute locally with `keytool` when a keystore is at hand. → automated.
  • Verification is unauthenticated: Google's authorization endpoint distinguishes an unknown client ("invalid_client" / "The OAuth client was not found") from a valid client with an unregistered redirect URI ("redirect_uri_mismatch"), so client IDs — and, for the web client, the registered redirect URI — are checkable without credentials. → automated.

Apple:

  • Bundle IDs and their capabilities are covered by the official App Store Connect API (bundleIds / bundleIdCapabilities resources, JWT-authed with an ASC API key). → automated: verify/create the bundle ID, enable the Sign in with Apple capability.
  • Sign in with Apple key creation is attempted through the ASC keys resource; the endpoint is not part of every documented ASC surface, so a 404 from Apple degrades to the guided flow (portal URL, then paste the key ID and the downloaded .p8 path). Apple serves the .p8 exactly once; the command uploads it into moth's encrypted provider config immediately.
  • Services IDs and their return-URL registration have NO official API (fastlane's spaceship uses the unofficial portal API). → GUIDED, with the exact values to paste; --unofficial-api is a documented stub, deliberately not implemented.
  • Verification: a client secret minted from the stored key is dry-run against Apple's token endpoint — "invalid_grant" proves the key/team/client triple is accepted, "invalid_client" proves it is not. → automated whenever the key material is in hand.

Every external call (Google endpoints, ASC, gcloud, keytool, prompts) sits behind an interface or injectable endpoint so tests run against doubles; nothing here talks to a real console in CI.

Index

Constants

View Source
const ASCBaseURL = "https://api.appstoreconnect.apple.com"

ASCBaseURL is the official App Store Connect API host.

View Source
const PubSubBaseURL = "https://pubsub.googleapis.com"

PubSubBaseURL is the Cloud Pub/Sub REST host.

Variables

View Source
var ErrUnofficialAPINotImplemented = errors.New(
	"--unofficial-api is not implemented: Services ID registration has no official API and the unofficial portal API was evaluated and deliberately not shipped; use the guided flow")

ErrUnofficialAPINotImplemented is returned for --unofficial-api.

View Source
var StripeWebhookEvents = []string{
	"checkout.session.completed",
	"customer.subscription.created",
	"customer.subscription.updated",
	"customer.subscription.deleted",
}

StripeWebhookEvents is the event set moth's webhook receiver consumes (plan/17): checkout completion plus the subscription lifecycle family.

Functions

func AppleBillingMissing

func AppleBillingMissing(a *adminv1.AppleBillingConfig) []string

AppleBillingMissing lists the required App Store Server API credential pieces that are absent; empty when the configuration is complete.

func AppleProviderConfigured

func AppleProviderConfigured(a *adminv1.AppleProviderConfig) bool

AppleProviderConfigured reports whether Sign in with Apple is usable: enabled with nothing missing.

func AppleProviderMissing

func AppleProviderMissing(a *adminv1.AppleProviderConfig) []string

AppleProviderMissing lists the required Sign in with Apple pieces that are absent (team ID, key ID, private key, services/bundle ID); empty when the configuration is complete. Enablement is the caller's concern.

func AppleProviderMissingNativeOnly

func AppleProviderMissingNativeOnly(a *adminv1.AppleProviderConfig) []string

AppleProviderMissingNativeOnly is the platform-aware variant of AppleProviderMissing for a project that ships on neither web nor Android: a bundle ID alone is sufficient there, because the native flow verifies Apple ID tokens against the bundle-ID audiences (internal/server/rpc/auth/oauth.go) and the Services ID / team ID / key ID / .p8 trio only signs the web-redirect flow and the best-effort code exchange. Used by the profile-aware setup checklist; `moth doctor` keeps AppleProviderMissing (it has no profile context). Enablement is the caller's concern.

func GoogleBillingMissing

func GoogleBillingMissing(g *adminv1.GoogleBillingConfig) []string

GoogleBillingMissing lists the required Play Developer API credential pieces that are absent; empty when the configuration is complete.

func GoogleProviderConfigured

func GoogleProviderConfigured(g *adminv1.GoogleProviderConfig) bool

GoogleProviderConfigured reports whether Sign in with Google is usable: enabled with at least one client ID.

func GoogleProviderHasClientID

func GoogleProviderHasClientID(g *adminv1.GoogleProviderConfig) bool

GoogleProviderHasClientID reports whether any Google client ID (web, iOS, Android) is configured — the minimum for Sign in with Google to work on at least one platform.

func KeystoreFingerprints

func KeystoreFingerprints(ctx context.Context, r Runner, keystore, storepass string) (sha1, sha256 string, err error)

KeystoreFingerprints computes the signing certificate fingerprints of a keystore with keytool. keytool cannot prompt through the captured-output runner (its stdin and stdout are pipes), so the caller must resolve the password first; storepass may be empty for keystores that list their certificate chain without one (JKS — PKCS12 keystores need the password).

func NormalizeFingerprint

func NormalizeFingerprint(s string, size int) (string, error)

NormalizeFingerprint validates a certificate fingerprint of size bytes (20 for SHA-1, 32 for SHA-256), accepting hex with or without colons, and returns the canonical upper-case colon-separated form.

func ParseKeytoolOutput

func ParseKeytoolOutput(out []byte) (sha1, sha256 string, err error)

ParseKeytoolOutput extracts the SHA-1 and SHA-256 certificate fingerprints from `keytool -list -v` output.

func StripeBillingMissing

func StripeBillingMissing(s *adminv1.StripeBillingConfig) []string

StripeBillingMissing lists the required Stripe credential pieces that are absent; empty when the configuration is complete.

func ValidateASCIssuerID

func ValidateASCIssuerID(s string) (string, error)

ValidateASCIssuerID checks an App Store Connect API issuer ID (a UUID).

func ValidateAppleKeyID

func ValidateAppleKeyID(s string) (string, error)

ValidateAppleKeyID checks a 10-character Apple key ID.

func ValidateAppleTeamID

func ValidateAppleTeamID(s string) (string, error)

ValidateAppleTeamID checks a 10-character Apple Developer Team ID.

func ValidateGoogleClientID

func ValidateGoogleClientID(s string) (string, error)

ValidateGoogleClientID checks the pasted value looks like an OAuth client ID and returns it trimmed.

func WireRTDN

func WireRTDN(ctx context.Context, ps *PubSub, topicID, subID, pushEndpoint string, res *SyncResult) error

WireRTDN creates the RTDN topic + push subscription and appends the result plus the always-guided "point Play Console at the topic" step (no Android Publisher API for it). ps may be nil to skip API creation and emit only the guided steps (honest fallback when no pubsub-scoped credential is available).

Types

type ASC

type ASC struct {
	// BaseURL defaults to ASCBaseURL; tests point it at a double.
	BaseURL  string
	IssuerID string
	KeyID    string
	Key      *ecdsa.PrivateKey
	HTTPC    oidc.Doer
	// Now is the clock (test override); defaults to time.Now.
	Now func() time.Time
}

ASC is a minimal App Store Connect API client covering exactly what `moth setup apple` needs: bundle IDs, their capabilities, and Sign in with Apple key creation. Requests are authenticated with a short-lived ES256 JWT minted from the operator's ASC API key — the key is used in-process and never persisted (plan: "store nothing platform-side").

func (*ASC) CreateBundleID

func (c *ASC) CreateBundleID(ctx context.Context, identifier, name string) (*ASCBundleID, error)

CreateBundleID registers an iOS bundle ID.

func (*ASC) CreateLocalization

func (c *ASC) CreateLocalization(ctx context.Context, subID string, t DesiredTier) error

CreateLocalization creates a subscription localization.

func (*ASC) CreateSignInWithAppleKey

func (c *ASC) CreateSignInWithAppleKey(ctx context.Context, name, primaryBundleResourceID string) (keyID string, p8 []byte, err error)

CreateSignInWithAppleKey creates a Sign in with Apple key and returns its key ID plus the .p8 contents — Apple serves the private key exactly once, so the caller must store it immediately. A 404 (isASCNotFound) means the endpoint is not available to this account/API surface; the caller then degrades to the guided portal flow (see the capability spike note).

func (*ASC) CreateSubscription

func (c *ASC) CreateSubscription(ctx context.Context, groupID string, t DesiredTier) (*ASCSubscription, error)

CreateSubscription creates an auto-renewable subscription in a group.

func (*ASC) CreateSubscriptionGroup

func (c *ASC) CreateSubscriptionGroup(ctx context.Context, appID, referenceName string) (*ASCSubscriptionGroup, error)

CreateSubscriptionGroup creates a subscription group under an app.

func (*ASC) CreateSubscriptionPrice

func (c *ASC) CreateSubscriptionPrice(ctx context.Context, subID, pricePointID string) error

CreateSubscriptionPrice attaches a price point to a subscription (base territory, effective immediately).

func (*ASC) CurrentPricePointIDs

func (c *ASC) CurrentPricePointIDs(ctx context.Context, subID string) ([]string, error)

CurrentPricePointIDs returns the price-point ids currently scheduled on a subscription, used to decide whether the base price already matches.

func (*ASC) EnableSignInWithApple

func (c *ASC) EnableSignInWithApple(ctx context.Context, bundleResourceID string) error

EnableSignInWithApple adds the APPLE_ID_AUTH capability to a bundle ID.

func (*ASC) FindBundleID

func (c *ASC) FindBundleID(ctx context.Context, identifier string) (*ASCBundleID, error)

FindBundleID looks a bundle ID up by its identifier; store.ErrNotFound semantics are represented by a nil result.

func (*ASC) FindLocalization

func (c *ASC) FindLocalization(ctx context.Context, subID, locale string) (*ASCLocalization, error)

FindLocalization returns the localization for a locale, or nil.

func (*ASC) FindPricePoint

func (c *ASC) FindPricePoint(ctx context.Context, subID, territory, want string) (string, error)

FindPricePoint resolves the price-point resource id whose customerPrice equals want in the given territory, or "" when the ladder has no exact match (the caller then emits a guided price-schedule step).

func (*ASC) FindSubscriptionGroup

func (c *ASC) FindSubscriptionGroup(ctx context.Context, appID, referenceName string) (*ASCSubscriptionGroup, error)

FindSubscriptionGroup returns the group with the given reference name, or nil.

func (*ASC) HasSignInWithApple

func (c *ASC) HasSignInWithApple(ctx context.Context, bundleResourceID string) (bool, error)

HasSignInWithApple reports whether the bundle ID already carries the APPLE_ID_AUTH capability.

func (*ASC) ListSubscriptions

func (c *ASC) ListSubscriptions(ctx context.Context, groupID string) ([]ASCSubscription, error)

ListSubscriptions returns the subscriptions in a group.

func (*ASC) RegisterServerNotificationURL

func (c *ASC) RegisterServerNotificationURL(ctx context.Context, appID, notifyURL string) error

RegisterServerNotificationURL attempts to set the App Store Server Notification V2 URL for the app. There is no stable public ASC endpoint for this; a 404 (isASCNotFound) means the caller degrades to the guided step.

func (*ASC) Token

func (c *ASC) Token() (string, error)

Token mints the ES256 request JWT (header: alg/kid/typ; claims: iss/iat/exp/aud per Apple's "Generating Tokens for API Requests").

func (*ASC) UpdateLocalization

func (c *ASC) UpdateLocalization(ctx context.Context, locID, name, description string) error

UpdateLocalization patches a localization's name / description.

func (*ASC) UpdateSubscription

func (c *ASC) UpdateSubscription(ctx context.Context, subID, name string, groupLevel int) error

UpdateSubscription patches a subscription's reference name / group level.

type ASCBundleID

type ASCBundleID struct {
	// ResourceID is the opaque ASC resource id (not the identifier).
	ResourceID string
	Identifier string
	Name       string
}

ASCBundleID is a registered bundle ID resource.

type ASCLocalization

type ASCLocalization struct {
	ResourceID  string
	Locale      string
	Name        string
	Description string
}

ASCLocalization is a subscription localization resource.

type ASCSubscription

type ASCSubscription struct {
	ResourceID string
	ProductID  string
	Name       string
	Period     string
	GroupLevel int
}

ASCSubscription is an auto-renewable subscription resource.

type ASCSubscriptionGroup

type ASCSubscriptionGroup struct {
	ResourceID    string
	ReferenceName string
}

ASCSubscriptionGroup is a subscription group resource.

type Action

type Action string

Action is what a sync did to one product or notification hook.

const (
	ActionCreated   Action = "created"
	ActionUpdated   Action = "updated"
	ActionUnchanged Action = "unchanged"
	// ActionManual means the store API cannot do it; see the ManualSteps.
	ActionManual Action = "manual"
	ActionFailed Action = "failed"
)

Sync actions.

type AppleCatalog

type AppleCatalog struct {
	ASC *ASC
	// AppID is the ASC app resource id the subscription group hangs off.
	AppID string
	// Territory is the base territory whose price-point ladder the base price
	// is resolved against; defaults to "USA".
	Territory string
	// NotificationURL is moth's App Store Server Notification V2 endpoint; when
	// set, Sync attempts to register it (guided fallback on 404).
	NotificationURL string
	// CurrentNotificationURL is the URL moth has already registered (from its
	// stored config). Apple exposes no read for it, so Sync diffs against this
	// value to stay idempotent, re-registering only on a change. On a
	// successful registration Sync updates it to NotificationURL.
	CurrentNotificationURL string
}

AppleCatalog reconciles moth's DesiredCatalog into App Store Connect using the milestone-08 ASC JWT client. Every external call goes through ASC.do, so the whole sync runs against an httptest double in tests.

func (*AppleCatalog) Sync

Sync reconciles the catalog into App Store Connect. It reads existing state, diffs, and creates/updates only what changed, so a second run reports no changes. Steps Apple's API cannot perform are returned as ManualSteps.

type AppleSetup

type AppleSetup struct {
	Projects adminv1connect.ProjectServiceClient
	Prompt   *Prompter
	Out      io.Writer
	// ASC is the App Store Connect client, authenticated with the
	// operator's API key (used in-process only, never stored).
	ASC   *ASC
	HTTPC oidc.Doer
	// AppleTokenBase is Apple's OAuth base URL (test override; the dry-run
	// verification posts to {base}/auth/token).
	AppleTokenBase string
	// BaseURL is the moth instance base URL (return-URL construction).
	BaseURL string

	// Inputs; empty ones are prompted for.
	Slug       string
	BundleID   string
	TeamID     string
	ServicesID string
	// RotateKey forces creating a fresh Sign in with Apple key even when
	// the project already stores one.
	RotateKey bool
	// UseUnofficialAPI is a documented stub: the spike evaluated driving
	// the developer portal's unofficial API (fastlane/spaceship precedent)
	// for Services ID + return-URL registration and deliberately did not
	// ship it — it is unversioned, ToS-gray and breaks silently. The flag
	// exists so scripts written against a future implementation fail
	// loudly today instead of half-running.
	UseUnofficialAPI bool
}

AppleSetup drives `moth setup apple` for one project.

func (*AppleSetup) Run

func (s *AppleSetup) Run(ctx context.Context) (*Report, error)

Run executes the flow and returns the verification checklist.

type BillingPeriod

type BillingPeriod string

BillingPeriod is a subscription renewal cadence, declared once and mapped to each store's own vocabulary (see applePeriod / googlePeriod).

const (
	PeriodWeekly    BillingPeriod = "weekly"
	PeriodMonthly   BillingPeriod = "monthly"
	PeriodTwoMonth  BillingPeriod = "two_month"
	PeriodQuarterly BillingPeriod = "quarterly"
	PeriodHalfYear  BillingPeriod = "half_year"
	PeriodYearly    BillingPeriod = "yearly"
)

Supported billing periods. Kept to the cadences both stores share.

type BillingSetup

type BillingSetup struct {
	Projects     adminv1connect.ProjectServiceClient
	Products     adminv1connect.ProductServiceClient
	BillingCreds adminv1connect.BillingCredentialsServiceClient
	Prompt       *Prompter
	Out          io.Writer
	// BaseURL is the moth instance base URL, used to build the notification
	// endpoints (/billing/apple/notifications/{slug}, /billing/google/rtdn/{slug}).
	BaseURL string

	// Slug identifies the project.
	Slug string
	// Yes skips the "push to the live stores?" confirmation (non-interactive).
	Yes bool

	// --- Apple ---
	// ASC is the App Store Connect API client (catalog push); nil skips the
	// Apple catalog push (credentials are still stored, push becomes guided).
	ASC *ASC
	// AppleAppID is the ASC app resource id the subscription group hangs off.
	AppleAppID string
	// Apple In-App-Purchase key material (stored into moth, used to verify).
	AppleIAPKeyID    string
	AppleIAPIssuerID string
	AppleBundleID    string
	AppleAppAppleID  string
	AppleIAPKey      *ecdsa.PrivateKey // parsed .p8; nil skips the Apple verify probe
	AppleIAPKeyP8    []byte            // raw .p8 to store encrypted; empty keeps the stored one
	// AppleNotificationSecret is the App Store Server Notifications shared secret
	// (stored encrypted; empty keeps the stored one).
	AppleNotificationSecret string
	// AppleServerAPIBase overrides the App Store Server API host the verify probe
	// reaches (test double). Empty uses billing's production host.
	AppleServerAPIBase string

	// --- Google ---
	// GoogleSA is the parsed Play Developer service account (catalog push +
	// verify); nil skips the Google catalog push and verify.
	GoogleSA *billing.GoogleServiceAccount
	// GoogleServiceAccountJSON is the raw SA JSON to store encrypted; empty keeps
	// the stored one.
	GoogleServiceAccountJSON []byte
	GooglePackageName        string
	// GooglePubsubTopic is the RTDN Cloud Pub/Sub topic (projects/X/topics/Y or a
	// bare topic id), stored into moth and wired to the RTDN push subscription.
	GooglePubsubTopic string
	// GoogleRTDNSecret authenticates the RTDN push webhook (stored encrypted;
	// empty keeps the stored one).
	GoogleRTDNSecret string
	// GoogleCatalogBaseURL overrides the Android Publisher host (catalog + verify;
	// test double).
	GoogleCatalogBaseURL string
	// GoogleTokenURL overrides the OAuth2 token endpoint the SA authenticates
	// against (test double). Empty uses the SA's token_uri.
	GoogleTokenURL string
	// GooglePubSubTokens is a pubsub-scoped token source for creating the RTDN
	// topic + push subscription; nil emits guided steps instead (the
	// androidpublisher-scoped billing SA cannot create Pub/Sub resources).
	GooglePubSubTokens TokenSource
	// PubSubBaseURL overrides the Cloud Pub/Sub host (test double).
	PubSubBaseURL string
	// GoogleCloudProject is the GCP project the RTDN topic/subscription live in;
	// defaults to the SA's project_id.
	GoogleCloudProject string

	// --- Stripe ---
	// Stripe enables the Stripe leg even when no secret key is provided this
	// run (credentials kept, push skipped with a warning). A non-empty
	// StripeSecretKey enables it implicitly.
	Stripe bool
	// StripeSecretKey is the project's restricted/secret key (sk_/rk_). It is
	// stored encrypted into moth's billing config AND used in-process for the
	// catalog push, webhook provisioning and verify probe — unlike ASC, the
	// same key drives provisioning and runtime. Empty keeps the stored one
	// (and skips the live Stripe calls: moth never returns stored secrets).
	StripeSecretKey string
	// StripeBaseURL overrides the Stripe API host (test double).
	StripeBaseURL string

	HTTPC billing.Doer
}

BillingSetup drives `moth setup billing` for one project: it stores the project's store API credentials into moth's encrypted billing config, pushes moth's product catalog into App Store Connect / Google Play, wires the notification endpoints, and verifies each store is reachable and authenticated.

It is the monetization counterpart to AppleSetup/GoogleSetup and inherits the same honest-automation contract: automate what the store APIs expose, fall back to a precise guided checklist (the AppleCatalog/GoogleCatalog ManualSteps) where they don't, and keep every run idempotent — the store-catalog clients read store state and change only deltas, so a second run reports no changes.

Every external dependency is a field so tests inject doubles: the admin RPC clients, the App Store Connect client (ASC, catalog push), the Google token source + base URLs, and the base URLs the verification probes reach the store APIs at. Store credentials are used in-process to drive the store APIs and persisted only as moth's own encrypted billing config — never leaked platform-side.

func (*BillingSetup) Run

func (s *BillingSetup) Run(ctx context.Context) (*Report, error)

Run stores credentials, pushes the catalog, wires notifications and verifies each configured store, returning the checklist. A non-nil error aborts before verification (bad input, RPC failure); store-side problems surface as checks.

type Check

type Check struct {
	Name   string `json:"name"`
	Status Status `json:"status"`
	// Detail says what was observed.
	Detail string `json:"detail,omitempty"`
	// Remediation says what to do about a WARN/FAIL.
	Remediation string `json:"remediation,omitempty"`
}

Check is one line of the final checklist.

type DesiredCatalog

type DesiredCatalog struct {
	// GroupReference is the App Store Connect subscription group reference
	// name; all tiers share one group. Ignored by Google (no group concept).
	GroupReference string
	Tiers          []DesiredTier
}

DesiredCatalog is moth's whole subscription catalog for one project's app — the desired state each store is reconciled into. Deliberately small: one subscription group, a few tiers (plan/12).

func DesiredCatalogFromProducts

func DesiredCatalogFromProducts(products []*adminv1.Product, slug, storeName string) DesiredCatalog

DesiredCatalogFromProducts builds the store DesiredCatalog from moth's admin product list for one store (billing.StoreApple / StoreGoogle / StoreStripe). It is the single mapping shared by `moth setup billing` and the admin MonetizationService handler — "one catalog, three faces" (plan/12): the Tiers slice is empty when no product targets the store.

type DesiredTier

type DesiredTier struct {
	// ProductID is the store product identifier, identical in App Store
	// Connect and Google Play.
	ProductID string
	// Reference is the internal reference name (Apple subscription "name",
	// never shown to customers).
	Reference string
	// DisplayName is the customer-facing localized name.
	DisplayName string
	// Description is the customer-facing localized description.
	Description string
	// Period is the renewal cadence.
	Period BillingPeriod
	// Price is the base (base-territory / base-region) price.
	Price Money
	// Locale is the BCP-47 localization locale, e.g. "en-US".
	Locale string
	// GroupLevel is the Apple ranking within the subscription group (1 = top
	// tier). Ignored by Google.
	GroupLevel int
	// Intro is an optional introductory offer / free trial.
	Intro *IntroOffer
	// StripePriceID / StripeProductID are the Stripe resources moth currently
	// records for this tier ("" when never provisioned). Unlike the Apple and
	// Google SKUs, Stripe ids are generated by provisioning rather than
	// authored, so the Stripe sync diffs against these and writes fresh ids
	// back through ProductResult. Ignored by Apple and Google.
	StripePriceID   string
	StripeProductID string
}

DesiredTier is one subscription product in moth's catalog, in a store-agnostic form. The same ProductID is used in both stores by moth convention (billing keys on it).

type Doctor

type Doctor struct {
	// BaseURL is the instance URL the checks reach it at (the context URL).
	BaseURL      string
	HTTPC        oidc.Doer
	Session      adminv1connect.SessionServiceClient
	Settings     adminv1connect.InstanceSettingsServiceClient
	Projects     adminv1connect.ProjectServiceClient
	BillingCreds adminv1connect.BillingCredentialsServiceClient
	Products     adminv1connect.ProductServiceClient

	// Slug selects a project for the provider checks; empty runs the
	// instance-level checks only.
	Slug string
	// SMTPTestTo, when set, sends a real test email to that address.
	SMTPTestTo string
	// AppleKeyPath optionally points at the project's Sign in with Apple
	// .p8 so the Apple dry-run can happen: moth stores the key encrypted
	// and never returns it, so a remote doctor cannot mint a client secret
	// without it.
	AppleKeyPath string
	// GoogleAuthURL and AppleTokenBase are test overrides.
	GoogleAuthURL  string
	AppleTokenBase string

	// AppleIAPKeyPath optionally points at the project's App Store Server API
	// In-App-Purchase .p8 so the billing check can probe the App Store Server
	// API (moth stores the key encrypted and never returns it).
	AppleIAPKeyPath string
	// GoogleServiceAccountPath optionally points at the Play Developer API
	// service-account JSON so the billing check can probe the Play API.
	GoogleServiceAccountPath string
	// StripeSecretKey optionally supplies the project's Stripe secret key so
	// the billing check can probe the Stripe API (moth stores the key
	// encrypted and never returns it, like the other store secrets).
	StripeSecretKey string
	// AppleServerAPIBase, GoogleAPIBase, GoogleTokenURL and StripeAPIBase are
	// test overrides for the billing store probes.
	AppleServerAPIBase string
	GoogleAPIBase      string
	GoogleTokenURL     string
	StripeAPIBase      string
}

Doctor runs the `moth doctor` health checks against one instance and, when Slug is set, one project. It never mutates anything (the optional SMTP test send excepted, which the operator requests explicitly).

func (*Doctor) Run

func (d *Doctor) Run(ctx context.Context) (*Report, error)

Run produces the health report. It returns an error only when the run itself could not proceed (no connection at all); individual problems are FAIL checks.

type ExecRunner

type ExecRunner struct{}

ExecRunner runs the real tools.

func (ExecRunner) LookPath

func (ExecRunner) LookPath(name string) (string, error)

LookPath implements Runner via exec.LookPath.

func (ExecRunner) Output

func (ExecRunner) Output(ctx context.Context, env []string, name string, args ...string) ([]byte, error)

Output implements Runner via exec.CommandContext.

type GoogleCatalog

type GoogleCatalog struct {
	// BaseURL defaults to billing.GooglePlayBaseURL; tests point it at a double.
	BaseURL     string
	PackageName string
	Tokens      TokenSource
	HTTPC       billing.Doer
	// RegionCode is the base region whose price the base plan is created with;
	// defaults to "US".
	RegionCode string
	// BasePlanID is the base-plan id created per subscription; defaults to
	// "base" (Google requires lowercase/digits/hyphen).
	BasePlanID string
}

GoogleCatalog reconciles moth's DesiredCatalog into Google Play using the Android Publisher API, authed with the milestone-11 service-account token source. All calls go through an injectable Doer + BaseURL for httptest.

func (*GoogleCatalog) Sync

Sync reconciles the catalog into Google Play: create/patch each subscription with its base plan + regional price, activate it, and report per-product actions. Idempotent — an unchanged tier is left alone on a re-run.

type GoogleSetup

type GoogleSetup struct {
	Projects adminv1connect.ProjectServiceClient
	Prompt   *Prompter
	Out      io.Writer
	Runner   Runner    // gcloud + keytool; ExecRunner in the CLI
	HTTPC    oidc.Doer // verification probes
	// AuthURL is Google's OAuth authorization endpoint (test override).
	AuthURL string
	// BaseURL is the moth instance base URL (redirect URI construction).
	BaseURL string

	// Inputs; empty ones are prompted for.
	Slug           string
	GCPProject     string
	IOSBundleID    string
	AndroidPackage string
	AndroidSHA1    string
	AndroidSHA256  string
	Keystore       string // compute fingerprints from this keystore
	KeystorePass   string
	// Pre-supplied client IDs skip the guided console visit.
	WebClientID     string
	IOSClientID     string
	AndroidClientID string
	WebClientSecret string
}

GoogleSetup drives `moth setup google` for one project. Every external dependency is a field so tests inject doubles.

func (*GoogleSetup) Run

func (s *GoogleSetup) Run(ctx context.Context) (*Report, error)

Run executes the flow and returns the verification checklist. A non-nil error aborts before verification (bad input, RPC failure); console-side problems surface as FAIL checks instead.

type IntroOffer

type IntroOffer struct {
	// Period is the intro duration (one billing period of Period length).
	Period BillingPeriod
	// FreeTrial makes the intro a free trial (Price ignored).
	FreeTrial bool
	// Price is the introductory amount when not a free trial.
	Price Money
}

IntroOffer is an optional introductory price / free trial for a tier. Free trials set FreeTrial and leave Price zero; paid intros set Price.

type ManualStep

type ManualStep struct {
	Title string
	// Reason says why the API cannot perform it.
	Reason string
	// URL is the console page to open, if any.
	URL string
	// Instructions are the exact lines/values to enter.
	Instructions []string
}

ManualStep is a guided-fallback instruction for something the API cannot do. Returned as structured data (never printed here) so the CLI and admin handler render it their own way, with the exact values to enter.

type Money

type Money struct {
	Currency string
	Micros   int64
}

Money is a price in micro-units of an ISO 4217 currency (9.99 USD -> Micros 9_990_000, Currency "USD"). Micros is the lossless representation both stores accept — Google natively (units+nanos), Apple after matching to the nearest price point.

type NotificationResult

type NotificationResult struct {
	// Kind is a stable slug, e.g. "apple_server_notification_url" or
	// "google_rtdn_topic".
	Kind     string
	Action   Action
	Endpoint string
	Detail   string
}

NotificationResult is the outcome of wiring one store notification hook.

type ProductResult

type ProductResult struct {
	// ProductID is moth's / the store product id (for Stripe, the moth tier
	// identifier — Stripe ids are generated, not authored).
	ProductID string
	// StoreID is the store's own resource id to map back into moth's product
	// (Apple subscription resource id; Google == ProductID; Stripe recurring
	// Price "price_...").
	StoreID string
	// StoreParentID is the parent resource StoreID hangs off, when the store
	// has a two-level catalog identity (Stripe: the Product "prod_..." owning
	// the Price in StoreID). Empty for Apple and Google.
	StoreParentID string
	Action        Action
	Detail        string
}

ProductResult is the per-tier outcome of a catalog sync.

type Prompter

type Prompter struct {
	// contains filtered or unexported fields
}

Prompter reads answers for the guided flows. In/out are plain streams so tests feed scripted input and assert the transcript.

func NewPrompter

func NewPrompter(in io.Reader, out io.Writer) *Prompter

NewPrompter wraps in/out for the guided flows.

func (*Prompter) Ask

func (p *Prompter) Ask(label string, validate func(string) (string, error)) (string, error)

Ask prints the label and reads a line until validate accepts it (which may normalize the value) or the attempts run out. An empty validate accepts anything, including "".

func (*Prompter) AskSecret

func (p *Prompter) AskSecret(label string) (string, error)

AskSecret prints the label and reads one secret line. When the input is a terminal the echo is disabled, so the value never lands in scrollback or session recordings; piped/scripted input falls back to a plain line read (nothing echoes there anyway).

func (*Prompter) Confirm

func (p *Prompter) Confirm(label string, def bool) (bool, error)

Confirm asks a yes/no question; empty input means the given default.

func (*Prompter) Say

func (p *Prompter) Say(format string, args ...any)

Say writes one line of guidance to the prompt transcript.

type PubSub

type PubSub struct {
	// BaseURL defaults to the Cloud Pub/Sub API host; tests override it.
	BaseURL string
	Project string
	Tokens  TokenSource
	HTTPC   billing.Doer
}

PubSub is a minimal Cloud Pub/Sub Admin client for the RTDN topic + push subscription. Tokens MUST be pubsub-scoped in production (distinct from the androidpublisher billing SA); the injectable BaseURL/Doer keep it testable.

func (*PubSub) EnsurePushSubscription

func (p *PubSub) EnsurePushSubscription(ctx context.Context, subID, topicID, pushEndpoint string) (bool, error)

EnsurePushSubscription creates a push subscription delivering to pushEndpoint (moth's /billing/google/rtdn/{slug}?token=…). Idempotent on the id.

func (*PubSub) EnsureTopic

func (p *PubSub) EnsureTopic(ctx context.Context, topicID string) (bool, error)

EnsureTopic creates the topic if missing (idempotent). Returns whether it was created.

func (*PubSub) TopicName

func (p *PubSub) TopicName(topicID string) string

TopicName is the fully-qualified topic resource name.

type Report

type Report struct {
	Checks []Check `json:"checks"`
}

Report is the checklist a setup command or doctor run produces.

func (*Report) Fail

func (r *Report) Fail(name, detail, remediation string)

Fail records a fatal problem.

func (*Report) Failed

func (r *Report) Failed() bool

Failed reports whether any check failed.

func (*Report) JSON

func (r *Report) JSON() ([]byte, error)

JSON renders the checklist for --json consumers.

func (*Report) Pass

func (r *Report) Pass(name, detail string)

Pass records a successful check.

func (*Report) Print

func (r *Report) Print(w io.Writer, color bool)

Print renders the checklist, colored when color is true.

func (*Report) Skip

func (r *Report) Skip(name, detail string)

Skip records a check that did not apply.

func (*Report) Status

func (r *Report) Status() Status

Status is the overall outcome: FAIL if any check failed, else WARN if any warned, else PASS.

func (*Report) Warn

func (r *Report) Warn(name, detail, remediation string)

Warn records a non-fatal problem.

type Runner

type Runner interface {
	// LookPath reports where name resolves on PATH, or an error.
	LookPath(name string) (string, error)
	// Output runs the command with the extra "KEY=value" environment
	// variables (nil for none — secrets travel through the environment,
	// never the world-readable argv) and returns its combined output; a
	// non-zero exit is an error that carries the output for diagnostics.
	Output(ctx context.Context, env []string, name string, args ...string) ([]byte, error)
}

Runner abstracts the local helper tools the setup flows shell out to (gcloud, keytool), so tests substitute canned output and CI never needs the tools installed.

type Status

type Status string

Status is the outcome of one verification check.

const (
	StatusPass Status = "PASS"
	StatusSkip Status = "SKIP"
	StatusWarn Status = "WARN"
	StatusFail Status = "FAIL"
)

Check outcomes, ordered from best to worst.

type StripeCatalog

type StripeCatalog struct {
	// BaseURL defaults to billing.StripeAPIBaseURL; tests point it at a double.
	BaseURL string
	// SecretKey is the project's sk_/rk_ secret key.
	SecretKey string
	HTTPC     billing.Doer
	Now       func() time.Time
}

StripeCatalog reconciles moth's DesiredCatalog into Stripe using the milestone-17 StripeClient (Products + recurring Prices). Unlike App Store Connect and Google Play, the Stripe API can do everything — honest automation is total (plan/17): no ManualSteps, and even the webhook endpoint is provisioned via the API (EnsureWebhookEndpoint).

Two Stripe-isms shape the sync:

  • Price immutability: a Stripe Price can never change amount, currency or cadence. A drifted tier therefore gets a NEW Price on the same Product and moth re-points the tier to it (write-back via ProductResult); existing subscribers keep their old price — Stripe's model, surfaced in the result Detail instead of pretending to edit in place.
  • Trials are set at checkout time (subscription_data[trial_period_days] on the Checkout Session, see billing.StripeTrialDays), not on the Price, so the catalog sync neither pushes nor drift-checks trial periods.
  • Product names ARE mutable: a moth display-name change renames the Stripe Product in place (GetProduct + UpdateProduct), reported in the ActionUpdated detail — no new Price is created for a name-only drift.

Introductory offers are not pushed (Stripe models them as coupons / subscription phases, out of the milestone's scope).

func (*StripeCatalog) EnsureWebhookEndpoint

func (c *StripeCatalog) EnsureWebhookEndpoint(ctx context.Context, url string) (ep billing.StripeWebhookEndpoint, created, repaired bool, err error)

EnsureWebhookEndpoint idempotently provisions moth's Stripe webhook endpoint: it lists the account's endpoints, returns an existing one matching url exactly (a real read+diff, unlike Apple's persisted-anchor idempotency), or creates one subscribed to StripeWebhookEvents. An existing endpoint is not taken at face value: one Stripe has disabled, or one missing any of moth's events (extra events are fine), is repaired in place via UpdateWebhookEndpoint — repaired reports that (created=false). The signing Secret is set only when created (Stripe reveals it exactly once) — the caller must persist it then or never; an update never returns it.

func (*StripeCatalog) Sync

Sync reconciles the catalog into Stripe: each tier without recorded ids gets a Product + recurring Price created (ids returned for write-back), each tier with ids is read back and diffed. Idempotent — an unchanged tier is left alone on a re-run. A tier-level problem (unrepresentable price) is an ActionFailed result, not a hard error, so the rest of the catalog still syncs.

type SyncResult

type SyncResult struct {
	// Store is billing.StoreApple or billing.StoreGoogle.
	Store         string
	Products      []ProductResult
	Notifications []NotificationResult
	// ManualSteps are the guided-fallback steps the API could not perform.
	ManualSteps []ManualStep
}

SyncResult is the store-agnostic output of one catalog push, consumed by the CLI checklist and the admin monetization screen.

func (*SyncResult) Changed

func (r *SyncResult) Changed() bool

Changed reports whether the sync altered store state (any product or notification created/updated). Idempotent re-runs return false — the "second run reports zero changes" acceptance criterion.

type TokenSource

type TokenSource interface {
	Token(ctx context.Context) (string, error)
}

TokenSource mints a bearer token for a Google API. billing.GoogleTokenSource satisfies it; the Pub/Sub wiring needs a pubsub-scoped one (see PubSub).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL