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
- Variables
- func AppleBillingMissing(a *adminv1.AppleBillingConfig) []string
- func AppleProviderConfigured(a *adminv1.AppleProviderConfig) bool
- func AppleProviderMissing(a *adminv1.AppleProviderConfig) []string
- func AppleProviderMissingNativeOnly(a *adminv1.AppleProviderConfig) []string
- func GoogleBillingMissing(g *adminv1.GoogleBillingConfig) []string
- func GoogleProviderConfigured(g *adminv1.GoogleProviderConfig) bool
- func GoogleProviderHasClientID(g *adminv1.GoogleProviderConfig) bool
- func KeystoreFingerprints(ctx context.Context, r Runner, keystore, storepass string) (sha1, sha256 string, err error)
- func NormalizeFingerprint(s string, size int) (string, error)
- func ParseKeytoolOutput(out []byte) (sha1, sha256 string, err error)
- func StripeBillingMissing(s *adminv1.StripeBillingConfig) []string
- func ValidateASCIssuerID(s string) (string, error)
- func ValidateAppleKeyID(s string) (string, error)
- func ValidateAppleTeamID(s string) (string, error)
- func ValidateGoogleClientID(s string) (string, error)
- func WireRTDN(ctx context.Context, ps *PubSub, topicID, subID, pushEndpoint string, ...) error
- type ASC
- func (c *ASC) CreateBundleID(ctx context.Context, identifier, name string) (*ASCBundleID, error)
- func (c *ASC) CreateLocalization(ctx context.Context, subID string, t DesiredTier) error
- func (c *ASC) CreateSignInWithAppleKey(ctx context.Context, name, primaryBundleResourceID string) (keyID string, p8 []byte, err error)
- func (c *ASC) CreateSubscription(ctx context.Context, groupID string, t DesiredTier) (*ASCSubscription, error)
- func (c *ASC) CreateSubscriptionGroup(ctx context.Context, appID, referenceName string) (*ASCSubscriptionGroup, error)
- func (c *ASC) CreateSubscriptionPrice(ctx context.Context, subID, pricePointID string) error
- func (c *ASC) CurrentPricePointIDs(ctx context.Context, subID string) ([]string, error)
- func (c *ASC) EnableSignInWithApple(ctx context.Context, bundleResourceID string) error
- func (c *ASC) FindBundleID(ctx context.Context, identifier string) (*ASCBundleID, error)
- func (c *ASC) FindLocalization(ctx context.Context, subID, locale string) (*ASCLocalization, error)
- func (c *ASC) FindPricePoint(ctx context.Context, subID, territory, want string) (string, error)
- func (c *ASC) FindSubscriptionGroup(ctx context.Context, appID, referenceName string) (*ASCSubscriptionGroup, error)
- func (c *ASC) HasSignInWithApple(ctx context.Context, bundleResourceID string) (bool, error)
- func (c *ASC) ListSubscriptions(ctx context.Context, groupID string) ([]ASCSubscription, error)
- func (c *ASC) RegisterServerNotificationURL(ctx context.Context, appID, notifyURL string) error
- func (c *ASC) Token() (string, error)
- func (c *ASC) UpdateLocalization(ctx context.Context, locID, name, description string) error
- func (c *ASC) UpdateSubscription(ctx context.Context, subID, name string, groupLevel int) error
- type ASCBundleID
- type ASCLocalization
- type ASCSubscription
- type ASCSubscriptionGroup
- type Action
- type AppleCatalog
- type AppleSetup
- type BillingPeriod
- type BillingSetup
- type Check
- type DesiredCatalog
- type DesiredTier
- type Doctor
- type ExecRunner
- type GoogleCatalog
- type GoogleSetup
- type IntroOffer
- type ManualStep
- type Money
- type NotificationResult
- type ProductResult
- type Prompter
- type PubSub
- type Report
- func (r *Report) Fail(name, detail, remediation string)
- func (r *Report) Failed() bool
- func (r *Report) JSON() ([]byte, error)
- func (r *Report) Pass(name, detail string)
- func (r *Report) Print(w io.Writer, color bool)
- func (r *Report) Skip(name, detail string)
- func (r *Report) Status() Status
- func (r *Report) Warn(name, detail, remediation string)
- type Runner
- type Status
- type StripeCatalog
- type SyncResult
- type TokenSource
Constants ¶
const ASCBaseURL = "https://api.appstoreconnect.apple.com"
ASCBaseURL is the official App Store Connect API host.
const PubSubBaseURL = "https://pubsub.googleapis.com"
PubSubBaseURL is the Cloud Pub/Sub REST host.
Variables ¶
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.
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 ¶
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 ¶
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 ¶
ValidateASCIssuerID checks an App Store Connect API issuer ID (a UUID).
func ValidateAppleKeyID ¶
ValidateAppleKeyID checks a 10-character Apple key ID.
func ValidateAppleTeamID ¶
ValidateAppleTeamID checks a 10-character Apple Developer Team ID.
func ValidateGoogleClientID ¶
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 ¶
CreateBundleID registers an iOS bundle ID.
func (*ASC) CreateLocalization ¶
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 ¶
CreateSubscriptionPrice attaches a price point to a subscription (base territory, effective immediately).
func (*ASC) CurrentPricePointIDs ¶
CurrentPricePointIDs returns the price-point ids currently scheduled on a subscription, used to decide whether the base price already matches.
func (*ASC) EnableSignInWithApple ¶
EnableSignInWithApple adds the APPLE_ID_AUTH capability to a bundle ID.
func (*ASC) FindBundleID ¶
FindBundleID looks a bundle ID up by its identifier; store.ErrNotFound semantics are represented by a nil result.
func (*ASC) FindLocalization ¶
FindLocalization returns the localization for a locale, or nil.
func (*ASC) FindPricePoint ¶
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 ¶
HasSignInWithApple reports whether the bundle ID already carries the APPLE_ID_AUTH capability.
func (*ASC) ListSubscriptions ¶
ListSubscriptions returns the subscriptions in a group.
func (*ASC) RegisterServerNotificationURL ¶
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 ¶
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 ¶
UpdateLocalization patches a localization's name / description.
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 ¶
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 ¶
ASCSubscriptionGroup is a subscription group resource.
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 ¶
func (a *AppleCatalog) Sync(ctx context.Context, cat DesiredCatalog) (*SyncResult, error)
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.
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).
type ExecRunner ¶
type ExecRunner struct{}
ExecRunner runs the real tools.
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 ¶
func (c *GoogleCatalog) Sync(ctx context.Context, cat DesiredCatalog) (*SyncResult, error)
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.
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 ¶
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 ¶
NewPrompter wraps in/out for the guided flows.
func (*Prompter) Ask ¶
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 ¶
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).
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 ¶
EnsureTopic creates the topic if missing (idempotent). Returns whether it was created.
type Report ¶
type Report struct {
Checks []Check `json:"checks"`
}
Report is the checklist a setup command or doctor run produces.
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 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 ¶
func (c *StripeCatalog) Sync(ctx context.Context, cat DesiredCatalog) (*SyncResult, error)
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.