Skip to content

feat(membership): confirm Apple IAP subscriptions - #2813

Merged
Innei merged 12 commits into
masterfrom
feat/apple-iap-membership
Aug 20, 2026
Merged

feat(membership): confirm Apple IAP subscriptions#2813
Innei merged 12 commits into
masterfrom
feat/apple-iap-membership

Conversation

@Innei

@Innei Innei commented Aug 20, 2026

Copy link
Copy Markdown
Member

Summary

  • Verify StoreKit 2 JWS with the App Store Server library and write the same memberships row the web checkout already uses (provider = apple).
  • Expose appleIap on GET /membership/plans, add POST /membership/apple/confirm, and bind ASSN V2 webhooks by originalTransactionId.
  • Surface Apple credentials + the Apple webhook URL in admin without changing the web checkout provider.

Companion iOS PR: Innei/Yohaku feat/apple-iap-membership.

Test plan

  • pnpm exec vitest run test/src/modules/membership in apps/core (121 passed)
  • Confirm a sandbox JWS creates an Apple membership and GET /membership/status matches
  • Confirm against a live Dodo/manual member returns 200 and does not rewrite the row
  • Confirm the same originalTransactionId on another reader returns 409 MEMBERSHIP_APPLE_ALREADY_BOUND
  • Admin membership settings show the Apple block and …/membership/webhook/apple

@safedep

safedep Bot commented Aug 20, 2026

Copy link
Copy Markdown

SafeDep Report Summary

Green Malicious Packages Badge Green Vulnerable Packages Badge Green Risky License Badge

Package Details
Package Malware Vulnerability Risky License Report
icon @apple/app-store-server-library @ 3.1.0
apps/core/package.json pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon @types/jsrsasign @ 10.5.15
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon @types/node @ 25.9.5
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon @types/node-fetch @ 2.6.13
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon base64url @ 3.0.1
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon jsrsasign @ 11.1.5
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon undici-types @ 7.24.6
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗

View complete scan results →

This report is generated by SafeDep GitHub App

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ccbf245661

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

rawType: 'apple.confirm',
})

if (!applied.applied && byReader?.provider === 'apple') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Rebind inactive non-Apple rows before applying confirmation

When a reader has an expired or cancelled Dodo subscription (or an existing manual row), the live-subscription guard is skipped, but applyMembershipState refuses to reuse the row because its provider differs from Apple. This fallback only handles existing Apple rows, so the Apple event is marked processed while the paid reader keeps the old inactive membership. Reset or explicitly rebind inactive non-Apple rows before applying the confirmation.

Useful? React with 👍 / 👎.

Comment on lines +104 to +106
plan,
status: 'active',
currentPeriodEnd: new Date(input.decoded.expiresDate),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve cancellations when a transaction is replayed

When a REFUND or REVOKE webhook has cancelled an Apple membership, replaying the originally confirmed JWS makes applyEvent return applied: false because that transaction ID was already processed. This fallback then unconditionally writes status: 'active' with the original expiration date, allowing a refunded or revoked subscription whose period end is still in the future to regain entitlement.

Useful? React with 👍 / 👎.

Comment on lines +87 to +88
nonEmpty(monthlyProductId) &&
nonEmpty(yearlyProductId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Require App Apple ID before advertising IAP

When appleAppAppleId is omitted, this availability check can still expose Apple IAP as enabled, even though AppleProvider.environmentsToTry then includes only the sandbox verifier. A production client can consequently display the products and complete payment, only for /apple/confirm to reject the production JWS. Include a valid App Apple ID in the production-readiness check or otherwise ensure production verification remains available.

Useful? React with 👍 / 👎.

Comment on lines +169 to +173
type,
customerId: decoded.appAccountToken ?? decoded.originalTransactionId,
subscriptionId: decoded.originalTransactionId,
currentPeriodEnd: new Date(decoded.expiresDate),
readerId: '',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Propagate Apple product changes into the membership plan

For a renewal after switching between monthly and yearly products, the decoded transaction contains the new productId, but the normalized event discards it and supplies no plan. applyMembershipState therefore retains existing.plan for DID_RENEW, while DID_CHANGE_RENEWAL_PREF performs no update unless event.plan exists, leaving the membership API reporting the previous plan indefinitely.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fa46138159

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

customerId: decoded.appAccountToken ?? decoded.originalTransactionId,
subscriptionId: decoded.originalTransactionId,
plan,
currentPeriodEnd: new Date(decoded.expiresDate),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use Apple's grace-period expiration for failed renewals

When a DID_FAIL_TO_RENEW notification has the GRACE_PERIOD subtype, the transaction's expiresDate is the already-ended subscription period; the usable entitlement deadline is gracePeriodExpiresDate from the signed renewal info. Storing the former here makes effectiveMembershipStatus immediately report expired despite mapping the event to on_hold, so paying readers lose access throughout Apple's billing grace period. Verify/decode the renewal info and use its grace-period expiration for this subtype.

Useful? React with 👍 / 👎.

Comment on lines +66 to +70
return {
appAccountToken: decoded.appAccountToken,
expiresDate: decoded.expiresDate,
originalTransactionId: decoded.originalTransactionId,
productId: decoded.productId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject revoked transactions during confirmation

When the submitted signed transaction represents a refunded or revoked purchase whose original expiresDate is still in the future, verification succeeds and this projection discards Apple's revocationDate; confirmAppleTransaction then emits an activated event and grants entitlement until the original period end. This permits a revoked receipt to regain access whenever the cancellation webhook has not yet been delivered, so propagate and reject the revocation fields before activating the membership.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 191fae9498

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

webhookSigningKeyConfigured: false,
}
const checks = getMembershipSetupChecks(props.value, status)
const appleChecks = getAppleIapSetupChecks(props.value, status)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Let a complete Apple setup satisfy the enable gate

On a fresh Apple-only installation, appleChecks is calculated but never included in setupComplete; that value still reflects only the Dodo provider, API key, product, and webhook checks. Consequently the Enable toggle remains disabled unless Dodo is also configured, while resolveAppleIapAvailability requires the shared enabled flag, making the newly added Apple IAP flow impossible to enable through this editor without unrelated Dodo credentials.

Useful? React with 👍 / 👎.

Comment on lines +66 to +70
return {
bundleId: Boolean(config.appleBundleId?.trim()),
issuerId: Boolean(config.appleIssuerId?.trim()),
keyId: Boolean(config.appleKeyId?.trim()),
monthlyProductId: Boolean(config.appleMonthlyProductId?.trim()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include the App Apple ID in Apple setup validation

When the other Apple fields are populated but appleAppAppleId is blank, non-numeric, or non-positive, this helper reports every Apple check as complete even though the backend's resolveAppleIapAvailability explicitly rejects that configuration. The editor therefore displays a misleading 6/6 completion state while /plans advertises Apple IAP as disabled and confirmation is rejected; validate the App Apple ID here with the same positive-integer rule.

Useful? React with 👍 / 👎.

REFUND: 'cancelled',
REVOKE: 'cancelled',
GRACE_PERIOD_EXPIRED: 'cancelled',
DID_CHANGE_RENEWAL_PREF: 'plan_changed',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Apply Apple renewal-extension notifications

When Apple sends RENEWAL_EXTENDED, the signed transaction carries the extended subscription deadline, but the notification is absent from this map and is consequently returned as unsupported_event. The membership retains its old currentPeriodEnd, so effectiveMembershipStatus expires the reader at the original renewal date even though Apple has extended their paid entitlement; normalize this notification as a renewal so the new expiration is persisted.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 90f82564aa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +48 to +52
if (!membershipComplete && appleComplete) {
return {
completedCount: appleCompletedCount,
setupComplete: true,
totalCount: appleValues.length,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Count Apple availability when gating premium content

The new Apple-only success branch allows an installation without Dodo credentials to enable membership, but EntitlementService.isMembershipPurchasable() still consults only resolveMembershipAvailability(), which returns false without a registered web provider and its credentials. In that Apple-only configuration, PostController.applyPaywall() skips the paywall and isPremiumLocked() returns false, exposing premium content to nonmembers even though Apple IAP is enabled; backend entitlement availability must also include resolveAppleIapAvailability().

Useful? React with 👍 / 👎.

Comment on lines +47 to +50
EXPIRED: 'cancelled',
REFUND: 'cancelled',
REVOKE: 'cancelled',
GRACE_PERIOD_EXPIRED: 'cancelled',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restore entitlement on REFUND_REVERSED

When Apple sends REFUND_REVERSED after a previously processed REFUND, this map returns no event type, so the webhook endpoint acknowledges it as unsupported_event and leaves the membership cancelled. A purchaser whose refund was reversed therefore remains locked out even when the restored transaction has an unexpired period; normalize this notification as an activation or renewal so its current expiration is applied.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d3e4d03e28

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +240 to +245
if (!bound) {
return {
ok: true,
applied: false,
ignored: 'missing_reader_metadata',
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Retain unmatched Apple revocation notifications

When a REFUND or REVOKE notification arrives before /apple/confirm has created the subscription row, this branch acknowledges it with 200 and stores no tombstone or pending state. The reader can later submit the original purchase JWS—which was signed before the refund and therefore has no revocationDate—and confirmAppleTransaction will activate the refunded subscription. Fresh evidence beyond the earlier revoked-JWS issue is that this unbound-webhook path discards the only newer revocation evidence, so persist terminal events for later binding or query Apple's current subscription state during confirmation.

Useful? React with 👍 / 👎.

Comment on lines +211 to +215
eventId: notificationUUID,
provider: 'apple',
type,
customerId: decoded.appAccountToken ?? decoded.originalTransactionId,
subscriptionId: decoded.originalTransactionId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Order Apple state changes by signed event time

Apple notifications are deduplicated only by their distinct notificationUUID and carry no ordering metadata into applyMembershipState, which overwrites the row in arrival order. If a delayed DID_RENEW is delivered after a newer REFUND or REVOKE, the renewal is accepted as a new event and changes the cancelled membership back to active until its period end. Propagate the notification's signed/event time and reject state changes older than the latest applied Apple event.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1cc682f759

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}

export function appleAccountTokenForReader(readerId: string): string {
const digest = createHmac('sha256', SECURITY.jwtSecret || ENCRYPT.key)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep Apple account tokens stable across secret rotations

When JWT_SECRET is rotated—or, without an explicit secret, the service moves to a host with a different machine-derived encryption key—this HMAC produces a different account token for every existing reader. Apple transactions retain the appAccountToken supplied with the original purchase, so subsequent restore or confirmation requests fail the token comparison in confirmAppleTransaction, including renewals/upgrades for otherwise valid subscriptions. Persist a stable per-reader token or derive it from a dedicated, non-rotating membership secret.

Useful? React with 👍 / 👎.

@Innei
Innei merged commit e7e838d into master Aug 20, 2026
14 checks passed
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