diff --git a/apps/admin/src/api/membership.ts b/apps/admin/src/api/membership.ts index 1249004d604..8f79951336b 100644 --- a/apps/admin/src/api/membership.ts +++ b/apps/admin/src/api/membership.ts @@ -2,6 +2,7 @@ import { getJson } from './http' export interface MembershipConfigStatus { apiKeyConfigured: boolean + applePrivateKeyConfigured?: boolean supportedProviders: string[] webhookSigningKeyConfigured: boolean } diff --git a/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.test.tsx b/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.test.tsx index b66d73455c1..56678c4dcd2 100644 --- a/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.test.tsx +++ b/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.test.tsx @@ -104,6 +104,9 @@ describe('MembershipConfigEditor', () => { expect(harness.container.textContent).toContain( 'https://mx.example.com/api/v3/membership/webhook/dodo', ) + expect(harness.container.textContent).toContain( + 'https://mx.example.com/api/v3/membership/webhook/apple', + ) for (const event of MEMBERSHIP_WEBHOOK_EVENTS) { expect(harness.container.textContent).toContain(event) } diff --git a/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.tsx b/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.tsx index a2a60b7943e..c34064cbdd4 100644 --- a/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.tsx +++ b/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.tsx @@ -9,6 +9,7 @@ import { KeyRound, PackageOpen, PlugZap, + Smartphone, Webhook, } from 'lucide-react' import { toast } from 'sonner' @@ -26,7 +27,9 @@ import { TextInput } from '~/ui/primitives/text-field' import { buildMembershipWebhookUrl, + getAppleIapSetupChecks, getMembershipSetupChecks, + getMembershipSetupProgress, MEMBERSHIP_WEBHOOK_EVENTS, type MembershipConfigValue, } from '../../utils/membership' @@ -135,23 +138,31 @@ export function MembershipConfigEditor(props: { const environment = props.value.environment || 'live_mode' const status = statusQuery.data ?? { apiKeyConfigured: false, + applePrivateKeyConfigured: false, supportedProviders: ['dodo'], webhookSigningKeyConfigured: false, } const checks = getMembershipSetupChecks(props.value, status) - const completedCount = Object.values(checks).filter(Boolean).length - const totalCount = Object.keys(checks).length - const setupComplete = completedCount === totalCount + const appleChecks = getAppleIapSetupChecks(props.value, status) + const { completedCount, setupComplete, totalCount } = + getMembershipSetupProgress(checks, appleChecks) const providerSupported = checks.provider const apiKeyConfigured = Boolean(props.value.apiKey?.trim()) || status.apiKeyConfigured const webhookKeyConfigured = Boolean(props.value.webhookSigningKey?.trim()) || status.webhookSigningKeyConfigured + const applePrivateKeyConfigured = + Boolean(props.value.applePrivateKey?.trim()) || + Boolean(status.applePrivateKeyConfigured) const webhookUrl = buildMembershipWebhookUrl( API_URL || location.origin, provider, ) + const appleWebhookUrl = buildMembershipWebhookUrl( + API_URL || location.origin, + 'apple', + ) const rawProviderOptions = getStringOptions(props.fields, 'provider') const providerOptions = rawProviderOptions.map((option) => ({ ...option, @@ -177,9 +188,9 @@ export function MembershipConfigEditor(props: { value: MembershipConfigValue[TKey], ) => props.onChange({ ...props.value, [key]: value }) - const copyWebhookUrl = async () => { + const copyWebhookUrl = async (url: string) => { try { - await navigator.clipboard.writeText(webhookUrl) + await navigator.clipboard.writeText(url) toast.success(t('settings.membership.webhook.copySuccess')) } catch { toast.error(t('settings.membership.webhook.copyFailed')) @@ -324,7 +335,7 @@ export function MembershipConfigEditor(props: { -
+
void copyWebhookUrl()} + onClick={() => void copyWebhookUrl(webhookUrl)} type="button" variant="secondary" > @@ -416,6 +427,98 @@ export function MembershipConfigEditor(props: {
+ +
+
+ +

+ {Object.values(appleChecks).filter(Boolean).length}/ + {Object.keys(appleChecks).length} +

+
+
+
+ update('appleBundleId', value)} + placeholder={t('settings.membership.apple.bundleId.placeholder')} + value={props.value.appleBundleId ?? ''} + /> + update('appleKeyId', value)} + placeholder={t('settings.membership.apple.keyId.placeholder')} + value={props.value.appleKeyId ?? ''} + /> + update('appleIssuerId', value)} + placeholder={t('settings.membership.apple.issuerId.placeholder')} + value={props.value.appleIssuerId ?? ''} + /> + update('appleAppAppleId', value)} + placeholder={t( + 'settings.membership.apple.appAppleId.placeholder', + )} + value={props.value.appleAppAppleId ?? ''} + /> +
+

+ {t('settings.membership.apple.appAppleId.description')} +

+ update('applePrivateKey', value)} + placeholder={t('settings.membership.apple.privateKey.placeholder')} + value={props.value.applePrivateKey ?? ''} + /> +
+ update('appleMonthlyProductId', value)} + placeholder={t('settings.membership.apple.monthly.placeholder')} + value={props.value.appleMonthlyProductId ?? ''} + /> + update('appleYearlyProductId', value)} + placeholder={t('settings.membership.apple.yearly.placeholder')} + value={props.value.appleYearlyProductId ?? ''} + /> +
+
+

+ {t('settings.membership.webhook.endpointLabel')} +

+
+ + {appleWebhookUrl} + + +
+

+ {t('settings.membership.apple.webhook.description')} +

+
+
+
) } diff --git a/apps/admin/src/features/settings/utils/membership.test.ts b/apps/admin/src/features/settings/utils/membership.test.ts index 1cf357bb884..72dcabf5f86 100644 --- a/apps/admin/src/features/settings/utils/membership.test.ts +++ b/apps/admin/src/features/settings/utils/membership.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import { buildMembershipWebhookUrl, + getAppleIapSetupChecks, getMembershipSetupChecks, + getMembershipSetupProgress, } from './membership' describe('buildMembershipWebhookUrl', () => { @@ -11,6 +13,12 @@ describe('buildMembershipWebhookUrl', () => { buildMembershipWebhookUrl('https://mx.example.com/api/v3/', 'dodo'), ).toBe('https://mx.example.com/api/v3/membership/webhook/dodo') }) + + it('builds the Apple webhook URL', () => { + expect( + buildMembershipWebhookUrl('https://mx.example.com/api/v3/', 'apple'), + ).toBe('https://mx.example.com/api/v3/membership/webhook/apple') + }) }) describe('getMembershipSetupChecks', () => { @@ -53,3 +61,85 @@ describe('getMembershipSetupChecks', () => { expect(Object.values(checks).every(Boolean)).toBe(true) }) }) + +describe('getAppleIapSetupChecks', () => { + it('is complete when all Apple fields or persisted private key are present', () => { + expect( + getAppleIapSetupChecks( + { + appleAppAppleId: '1234567890', + appleBundleId: 'dev.yohaku.app', + appleIssuerId: 'ISSUER', + appleKeyId: 'KEYID', + appleMonthlyProductId: 'monthly', + appleYearlyProductId: 'yearly', + }, + { + apiKeyConfigured: false, + applePrivateKeyConfigured: true, + supportedProviders: ['dodo'], + webhookSigningKeyConfigured: false, + }, + ), + ).toEqual({ + appAppleId: true, + bundleId: true, + issuerId: true, + keyId: true, + monthlyProductId: true, + privateKey: true, + yearlyProductId: true, + }) + }) + + it.each(['', '0', '-1', '1.5', 'not-a-number'])( + 'rejects an invalid App Apple ID: %s', + (appleAppAppleId) => { + const checks = getAppleIapSetupChecks( + { + appleAppAppleId, + appleBundleId: 'dev.yohaku.app', + appleIssuerId: 'ISSUER', + appleKeyId: 'KEYID', + appleMonthlyProductId: 'monthly', + applePrivateKey: 'private-key', + appleYearlyProductId: 'yearly', + }, + { + apiKeyConfigured: false, + supportedProviders: ['dodo'], + webhookSigningKeyConfigured: false, + }, + ) + + expect(checks.appAppleId).toBe(false) + }, + ) + + it('lets a complete Apple-only setup satisfy the enable gate', () => { + const status = { + apiKeyConfigured: false, + supportedProviders: ['dodo'], + webhookSigningKeyConfigured: false, + } + const membershipChecks = getMembershipSetupChecks({}, status) + const appleChecks = getAppleIapSetupChecks( + { + appleAppAppleId: '1234567890', + appleBundleId: 'dev.yohaku.app', + appleIssuerId: 'ISSUER', + appleKeyId: 'KEYID', + appleMonthlyProductId: 'monthly', + applePrivateKey: 'private-key', + appleYearlyProductId: 'yearly', + }, + status, + ) + + expect(getMembershipSetupProgress(membershipChecks, appleChecks)).toEqual({ + completedCount: 7, + setupComplete: true, + totalCount: 7, + }) + }) +}) diff --git a/apps/admin/src/features/settings/utils/membership.ts b/apps/admin/src/features/settings/utils/membership.ts index ee8b5e90cf8..1c095e632a0 100644 --- a/apps/admin/src/features/settings/utils/membership.ts +++ b/apps/admin/src/features/settings/utils/membership.ts @@ -9,6 +9,13 @@ export const MEMBERSHIP_WEBHOOK_EVENTS = [ export interface MembershipConfigValue { apiKey?: string + appleAppAppleId?: string + appleBundleId?: string + appleIssuerId?: string + appleKeyId?: string + appleMonthlyProductId?: string + applePrivateKey?: string + appleYearlyProductId?: string enabled?: boolean environment?: string monthlyProductId?: string @@ -19,10 +26,40 @@ export interface MembershipConfigValue { export interface MembershipCredentialStatus { apiKeyConfigured: boolean + applePrivateKeyConfigured?: boolean supportedProviders: string[] webhookSigningKeyConfigured: boolean } +type SetupChecks = Record + +export function getMembershipSetupProgress( + membershipChecks: SetupChecks, + appleChecks: SetupChecks, +) { + const membershipValues = Object.values(membershipChecks) + const appleValues = Object.values(appleChecks) + const membershipCompletedCount = membershipValues.filter(Boolean).length + const appleCompletedCount = appleValues.filter(Boolean).length + const membershipComplete = + membershipCompletedCount === membershipValues.length + const appleComplete = appleCompletedCount === appleValues.length + + if (!membershipComplete && appleComplete) { + return { + completedCount: appleCompletedCount, + setupComplete: true, + totalCount: appleValues.length, + } + } + + return { + completedCount: membershipCompletedCount, + setupComplete: membershipComplete, + totalCount: membershipValues.length, + } +} + export function buildMembershipWebhookUrl(apiUrl: string, provider = 'dodo') { return `${apiUrl.replace(/\/+$/, '')}/membership/webhook/${encodeURIComponent(provider)}` } @@ -47,3 +84,22 @@ export function getMembershipSetupChecks( webhookSigningKey: hasWebhookSigningKey, } } + +export function getAppleIapSetupChecks( + config: MembershipConfigValue, + status?: MembershipCredentialStatus, +) { + const appAppleId = Number(config.appleAppAppleId?.trim()) + const hasPrivateKey = + Boolean(config.applePrivateKey?.trim()) || + Boolean(status?.applePrivateKeyConfigured) + return { + appAppleId: Number.isSafeInteger(appAppleId) && appAppleId > 0, + bundleId: Boolean(config.appleBundleId?.trim()), + issuerId: Boolean(config.appleIssuerId?.trim()), + keyId: Boolean(config.appleKeyId?.trim()), + monthlyProductId: Boolean(config.appleMonthlyProductId?.trim()), + privateKey: hasPrivateKey, + yearlyProductId: Boolean(config.appleYearlyProductId?.trim()), + } +} diff --git a/apps/admin/src/i18n/resources/en-US.ts b/apps/admin/src/i18n/resources/en-US.ts index b652d9bd6d0..5da75e37565 100644 --- a/apps/admin/src/i18n/resources/en-US.ts +++ b/apps/admin/src/i18n/resources/en-US.ts @@ -2648,6 +2648,30 @@ export const enUS = { 'settings.membership.webhook.signingKeyPlaceholder': 'Paste the endpoint Secret Key', 'settings.membership.webhook.title': 'Webhook delivery', + 'settings.membership.apple.title': 'App Store in-app purchase', + 'settings.membership.apple.description': + 'iOS subscriptions use StoreKit, not the web checkout provider. Fill every field to expose the in-app buy buttons.', + 'settings.membership.apple.bundleId.label': 'Bundle ID', + 'settings.membership.apple.bundleId.placeholder': 'dev.yohaku.app', + 'settings.membership.apple.keyId.label': 'Key ID', + 'settings.membership.apple.keyId.placeholder': 'Ten-character key ID', + 'settings.membership.apple.issuerId.label': 'Issuer ID', + 'settings.membership.apple.issuerId.placeholder': 'App Store Connect issuer', + 'settings.membership.apple.privateKey.label': '.p8 private key', + 'settings.membership.apple.privateKey.description': + 'Full contents of the In-App Purchase key, including the BEGIN and END lines.', + 'settings.membership.apple.privateKey.placeholder': + '-----BEGIN PRIVATE KEY-----', + 'settings.membership.apple.monthly.label': 'Monthly product ID', + 'settings.membership.apple.monthly.placeholder': 'yohaku.membership.monthly', + 'settings.membership.apple.yearly.label': 'Yearly product ID', + 'settings.membership.apple.yearly.placeholder': 'yohaku.membership.yearly', + 'settings.membership.apple.appAppleId.label': 'App Apple ID', + 'settings.membership.apple.appAppleId.description': + 'Numeric App Store app id. Required to verify production transactions.', + 'settings.membership.apple.appAppleId.placeholder': '1234567890', + 'settings.membership.apple.webhook.description': + 'Point App Store Server Notifications V2 at this URL.', 'settings.oauth.action.save': 'Save config', 'settings.oauth.apple.bundleIdHelp': 'Bundle ID of the native app, used only to verify id tokens signed for iOS. Leave blank for web-only sign-in.', diff --git a/apps/admin/src/i18n/resources/zh-CN.ts b/apps/admin/src/i18n/resources/zh-CN.ts index afeec7f07aa..dc86cfb282a 100644 --- a/apps/admin/src/i18n/resources/zh-CN.ts +++ b/apps/admin/src/i18n/resources/zh-CN.ts @@ -2511,6 +2511,30 @@ export const zhCN = { 'settings.membership.webhook.signingKeyPlaceholder': '粘贴该 Endpoint 的 Secret Key', 'settings.membership.webhook.title': 'Webhook 回调', + 'settings.membership.apple.title': 'App Store 内购', + 'settings.membership.apple.description': + 'iOS 订阅走 StoreKit,不占用网页结账渠道。六项都填齐后,App 才会显示购买入口。', + 'settings.membership.apple.bundleId.label': 'Bundle ID', + 'settings.membership.apple.bundleId.placeholder': 'dev.yohaku.app', + 'settings.membership.apple.keyId.label': 'Key ID', + 'settings.membership.apple.keyId.placeholder': '10 位密钥 ID', + 'settings.membership.apple.issuerId.label': 'Issuer ID', + 'settings.membership.apple.issuerId.placeholder': 'App Store Connect Issuer', + 'settings.membership.apple.privateKey.label': '.p8 私钥', + 'settings.membership.apple.privateKey.description': + '粘贴 In-App Purchase 密钥全文,含 BEGIN PRIVATE KEY 与 END PRIVATE KEY。', + 'settings.membership.apple.privateKey.placeholder': + '-----BEGIN PRIVATE KEY-----', + 'settings.membership.apple.monthly.label': '月付商品 ID', + 'settings.membership.apple.monthly.placeholder': 'yohaku.membership.monthly', + 'settings.membership.apple.yearly.label': '年付商品 ID', + 'settings.membership.apple.yearly.placeholder': 'yohaku.membership.yearly', + 'settings.membership.apple.appAppleId.label': 'App Apple ID', + 'settings.membership.apple.appAppleId.description': + 'App Store 数字应用 ID。正式环境验签需要填写。', + 'settings.membership.apple.appAppleId.placeholder': '1234567890', + 'settings.membership.apple.webhook.description': + '将 App Store Server Notifications V2 指向此地址。', 'settings.oauth.action.save': '保存配置', 'settings.oauth.apple.bundleIdHelp': '原生 App 的 Bundle ID,仅用于校验 iOS 端签发的 id token。纯 Web 登录可留空。', diff --git a/apps/core/package.json b/apps/core/package.json index cd47b6fc3a4..8f27ae60378 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -56,6 +56,7 @@ }, "dependencies": { "@antfu/install-pkg": "2.0.1", + "@apple/app-store-server-library": "^3.1.0", "@babel/core": "8.0.1", "@babel/plugin-transform-modules-commonjs": "8.0.1", "@babel/plugin-transform-typescript": "8.0.1", diff --git a/apps/core/src/common/errors/app-error-code.ts b/apps/core/src/common/errors/app-error-code.ts index 54ade700a32..315bdaa1d30 100644 --- a/apps/core/src/common/errors/app-error-code.ts +++ b/apps/core/src/common/errors/app-error-code.ts @@ -172,6 +172,8 @@ export enum AppErrorCode { MEMBERSHIP_PROVIDER_NOT_CONFIGURED = 'MEMBERSHIP_PROVIDER_NOT_CONFIGURED', MEMBERSHIP_PROVIDER_NOT_SUPPORTED = 'MEMBERSHIP_PROVIDER_NOT_SUPPORTED', MEMBERSHIP_ALREADY_ACTIVE = 'MEMBERSHIP_ALREADY_ACTIVE', + MEMBERSHIP_APPLE_TRANSACTION_INVALID = 'MEMBERSHIP_APPLE_TRANSACTION_INVALID', + MEMBERSHIP_APPLE_ALREADY_BOUND = 'MEMBERSHIP_APPLE_ALREADY_BOUND', PREMIUM_REQUIRES_LEXICAL = 'PREMIUM_REQUIRES_LEXICAL', // page diff --git a/apps/core/src/common/errors/app-error-definitions.ts b/apps/core/src/common/errors/app-error-definitions.ts index 96002f756d0..5f834b2de82 100644 --- a/apps/core/src/common/errors/app-error-definitions.ts +++ b/apps/core/src/common/errors/app-error-definitions.ts @@ -630,6 +630,14 @@ export const APP_ERROR_DEFINITIONS = { status: 409, message: 'Reader already has an active membership', }, + [AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID]: { + status: 400, + message: 'Apple transaction could not be verified', + }, + [AppErrorCode.MEMBERSHIP_APPLE_ALREADY_BOUND]: { + status: 409, + message: 'This Apple subscription is already linked to another reader', + }, [AppErrorCode.PREMIUM_REQUIRES_LEXICAL]: { status: 400, message: 'Only Lexical-format posts can be marked as premium', diff --git a/apps/core/src/common/errors/app-error-payload.ts b/apps/core/src/common/errors/app-error-payload.ts index 44744e52d0c..97c6d9a1c84 100644 --- a/apps/core/src/common/errors/app-error-payload.ts +++ b/apps/core/src/common/errors/app-error-payload.ts @@ -182,6 +182,8 @@ export type AppErrorPayloadMap = { [AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED]: undefined [AppErrorCode.MEMBERSHIP_PROVIDER_NOT_SUPPORTED]: undefined [AppErrorCode.MEMBERSHIP_ALREADY_ACTIVE]: undefined + [AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID]: undefined + [AppErrorCode.MEMBERSHIP_APPLE_ALREADY_BOUND]: undefined [AppErrorCode.PREMIUM_REQUIRES_LEXICAL]: undefined // page diff --git a/apps/core/src/modules/configs/configs.default.ts b/apps/core/src/modules/configs/configs.default.ts index 63b82b41e44..019d4b5b3e1 100644 --- a/apps/core/src/modules/configs/configs.default.ts +++ b/apps/core/src/modules/configs/configs.default.ts @@ -213,5 +213,12 @@ export const generateDefaultConfig: () => IConfig = () => ({ apiKey: '', webhookSigningKey: '', environment: 'live_mode', + appleBundleId: '', + appleKeyId: '', + appleIssuerId: '', + applePrivateKey: '', + appleMonthlyProductId: '', + appleYearlyProductId: '', + appleAppAppleId: '', }, }) diff --git a/apps/core/src/modules/configs/configs.schema.ts b/apps/core/src/modules/configs/configs.schema.ts index 83dd4ed1a2e..6fc7a2a328b 100644 --- a/apps/core/src/modules/configs/configs.schema.ts +++ b/apps/core/src/modules/configs/configs.schema.ts @@ -1163,6 +1163,22 @@ export const MembershipSchema = section('Membership', { { label: 'Test mode', value: 'test_mode' }, ], ), + appleBundleId: field.halfGrid(z.string().optional(), 'Apple bundle ID'), + appleKeyId: field.halfGrid(z.string().optional(), 'Apple key ID'), + appleIssuerId: field.plain(z.string().optional(), 'Apple issuer ID'), + applePrivateKey: field.password( + z.string().optional(), + 'Apple .p8 private key', + ), + appleMonthlyProductId: field.halfGrid( + z.string().optional(), + 'Apple monthly product ID', + ), + appleYearlyProductId: field.halfGrid( + z.string().optional(), + 'Apple yearly product ID', + ), + appleAppAppleId: field.plain(z.string().optional(), 'Apple app Apple ID'), }) export class MembershipDto extends createZodDto(MembershipSchema) {} export type MembershipConfig = z.infer diff --git a/apps/core/src/modules/membership/billing-webhook-event.repository.ts b/apps/core/src/modules/membership/billing-webhook-event.repository.ts index 00447b0c0f4..446b0332fce 100644 --- a/apps/core/src/modules/membership/billing-webhook-event.repository.ts +++ b/apps/core/src/modules/membership/billing-webhook-event.repository.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common' -import { and, eq } from 'drizzle-orm' +import { and, asc, desc, eq, isNotNull, isNull, sql } from 'drizzle-orm' import { PG_DB_TOKEN } from '~/constants/system.constant' import { billingWebhookEvents } from '~/database/schema' @@ -76,6 +76,53 @@ export class BillingWebhookEventRepository extends BaseRepository { return row ? mapRow(row) : null } + async findPendingByProviderSubscriptionId( + provider: string, + providerSubscriptionId: string, + ): Promise { + const rows = await this.db + .select() + .from(billingWebhookEvents) + .where( + and( + eq(billingWebhookEvents.provider, provider), + isNull(billingWebhookEvents.processedAt), + sql`${billingWebhookEvents.payload}->'_normalizedMembershipEvent'->>'subscriptionId' = ${providerSubscriptionId}`, + )!, + ) + .orderBy( + asc( + sql`${billingWebhookEvents.payload}->'_normalizedMembershipEvent'->>'occurredAt'`, + ), + asc(billingWebhookEvents.receivedAt), + ) + return rows.map(mapRow) + } + + async findLatestProcessedByProviderSubscriptionId( + provider: string, + providerSubscriptionId: string, + ): Promise { + const [row] = await this.db + .select() + .from(billingWebhookEvents) + .where( + and( + eq(billingWebhookEvents.provider, provider), + isNotNull(billingWebhookEvents.processedAt), + sql`${billingWebhookEvents.payload}->'_normalizedMembershipEvent'->>'subscriptionId' = ${providerSubscriptionId}`, + )!, + ) + .orderBy( + desc( + sql`${billingWebhookEvents.payload}->'_normalizedMembershipEvent'->>'occurredAt'`, + ), + desc(billingWebhookEvents.receivedAt), + ) + .limit(1) + return row ? mapRow(row) : null + } + async markProcessed( id: EntityId | string, processedAt: Date, diff --git a/apps/core/src/modules/membership/entitlement.service.ts b/apps/core/src/modules/membership/entitlement.service.ts index b50994b2406..7692e26dd36 100644 --- a/apps/core/src/modules/membership/entitlement.service.ts +++ b/apps/core/src/modules/membership/entitlement.service.ts @@ -4,6 +4,7 @@ import { ConfigsService } from '../configs/configs.service' import { MembershipRepository } from './membership.repository' import { type MembershipAvailability, + resolveAppleIapAvailability, resolveMembershipAvailability, } from './membership.types' @@ -62,6 +63,10 @@ export class EntitlementService { } async isMembershipPurchasable(): Promise { - return (await this.getAvailability()).enabled + const config = await this.configsService.get('membership') + return ( + resolveMembershipAvailability(config).enabled || + resolveAppleIapAvailability(config).enabled + ) } } diff --git a/apps/core/src/modules/membership/membership.controller.ts b/apps/core/src/modules/membership/membership.controller.ts index 96ab6a06deb..eb6c03c5384 100644 --- a/apps/core/src/modules/membership/membership.controller.ts +++ b/apps/core/src/modules/membership/membership.controller.ts @@ -30,9 +30,12 @@ import { MembershipService } from './membership.service' import { effectiveMembershipStatus, REGISTERED_PAYMENT_PROVIDERS, + resolveAppleIapAvailability, resolveMembershipAvailability, resolveMembershipReturnUrl, } from './membership.types' +import { AppleProvider } from './providers/apple.provider' +import { appleAccountTokenForReader } from './providers/apple-transaction' import { isIgnoredBillingEvent } from './providers/provider.interface' import { PaymentProviderRegistry } from './providers/provider.registry' @@ -59,6 +62,11 @@ const ReaderIdParamSchema = z.object({ }) class ReaderIdParamDto extends createZodDto(ReaderIdParamSchema) {} +const AppleConfirmSchema = z.object({ + signedTransactionInfo: z.string().min(1), +}) +class AppleConfirmDto extends createZodDto(AppleConfirmSchema) {} + const assertNotDemoMode = () => { if (DEMO_MODE) { throw createAppException(AppErrorCode.DEMO_FORBIDDEN) @@ -71,6 +79,7 @@ export class MembershipController { private readonly membershipService: MembershipService, private readonly configsService: ConfigsService, private readonly providers: PaymentProviderRegistry, + private readonly appleProvider: AppleProvider, ) {} @ReaderAuth() @@ -114,7 +123,10 @@ export class MembershipController { async plans() { const membershipConfig = await this.configsService.get('membership') const availability = resolveMembershipAvailability(membershipConfig) - if (!availability.enabled) return { enabled: false, plans: [] } + const appleIap = resolveAppleIapAvailability(membershipConfig) + if (!availability.enabled) { + return { enabled: false, plans: [], appleIap } + } const adapter = this.providers.get(membershipConfig.provider) const productIdByPlan: Record = { @@ -133,7 +145,43 @@ export class MembershipController { }), ) - return { enabled: true, plans } + return { enabled: true, plans, appleIap } + } + + @ReaderAuth() + @Get('/apple/account-token') + appleAccountToken(@CurrentUser() user: SessionUser) { + return { accountToken: appleAccountTokenForReader(user.id) } + } + + @ReaderAuth() + @Post('/apple/confirm') + @HttpCode(200) + async confirmApple( + @Body() body: AppleConfirmDto, + @CurrentUser() user: SessionUser, + ) { + assertNotDemoMode() + + const membershipConfig = await this.configsService.get('membership') + const appleIap = resolveAppleIapAvailability(membershipConfig) + if ( + !appleIap.enabled || + !appleIap.monthlyProductId || + !appleIap.yearlyProductId + ) { + throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) + } + + const decoded = await this.appleProvider.verifySignedTransaction( + body.signedTransactionInfo, + ) + return this.membershipService.confirmAppleTransaction({ + decoded, + monthlyProductId: appleIap.monthlyProductId, + readerId: user.id, + yearlyProductId: appleIap.yearlyProductId, + }) } @Auth() @@ -143,6 +191,7 @@ export class MembershipController { return { apiKeyConfigured: Boolean(membershipConfig.apiKey), + applePrivateKeyConfigured: Boolean(membershipConfig.applePrivateKey), supportedProviders: REGISTERED_PAYMENT_PROVIDERS, webhookSigningKeyConfigured: Boolean(membershipConfig.webhookSigningKey), } @@ -184,6 +233,22 @@ export class MembershipController { if (isIgnoredBillingEvent(verified)) { return { ok: true, applied: false, ignored: verified.reason } } + if (!verified.event.readerId) { + const bound = await this.membershipService.getByProviderSubscriptionId( + verified.event.subscriptionId, + ) + if (!bound) { + if (verified.event.provider === 'apple') { + await this.membershipService.deferEvent(verified) + } + return { + ok: true, + applied: false, + ignored: 'missing_reader_metadata', + } + } + verified.event.readerId = bound.readerId + } const result = await this.membershipService.applyEvent(verified) return { ok: true, applied: result.applied } } diff --git a/apps/core/src/modules/membership/membership.module.ts b/apps/core/src/modules/membership/membership.module.ts index 4112419a534..4638f9acbf4 100644 --- a/apps/core/src/modules/membership/membership.module.ts +++ b/apps/core/src/modules/membership/membership.module.ts @@ -5,6 +5,7 @@ import { EntitlementService } from './entitlement.service' import { MembershipController } from './membership.controller' import { MembershipRepository } from './membership.repository' import { MembershipService } from './membership.service' +import { AppleProvider } from './providers/apple.provider' import { DodoProvider } from './providers/dodo.provider' import { PaymentProviderRegistry } from './providers/provider.registry' @@ -15,6 +16,7 @@ import { PaymentProviderRegistry } from './providers/provider.registry' EntitlementService, MembershipRepository, BillingWebhookEventRepository, + AppleProvider, DodoProvider, PaymentProviderRegistry, ], diff --git a/apps/core/src/modules/membership/membership.service.ts b/apps/core/src/modules/membership/membership.service.ts index 191f57c2246..d98b8fc9823 100644 --- a/apps/core/src/modules/membership/membership.service.ts +++ b/apps/core/src/modules/membership/membership.service.ts @@ -4,22 +4,99 @@ import { AppErrorCode, createAppException } from '~/common/errors' import { BillingWebhookEventRepository } from './billing-webhook-event.repository' import { MembershipRepository } from './membership.repository' -import type { - MembershipPlan, - MembershipProvider, - MembershipRow, +import { + effectiveMembershipStatus, + type MembershipPlan, + type MembershipProvider, + type MembershipRow, + type MembershipStatus, } from './membership.types' +import { + appleAccountTokenForReader, + appleActivatedEvent, + type AppleDecodedTransaction, + planFromAppleProductId, +} from './providers/apple-transaction' import type { NormalizedBillingEvent, VerifiedBillingEvent, } from './providers/provider.interface' +const NORMALIZED_EVENT_PAYLOAD_KEY = '_normalizedMembershipEvent' + +type StoredNormalizedBillingEvent = Omit< + NormalizedBillingEvent, + 'currentPeriodEnd' | 'occurredAt' +> & { + currentPeriodEnd: string + occurredAt?: string +} + +const storeBillingEventPayload = ( + event: NormalizedBillingEvent, + rawPayload: unknown, +): Record => { + const payload = + rawPayload && typeof rawPayload === 'object' && !Array.isArray(rawPayload) + ? { ...(rawPayload as Record) } + : { rawPayload } + const storedEvent: StoredNormalizedBillingEvent = { + ...event, + currentPeriodEnd: event.currentPeriodEnd.toISOString(), + occurredAt: event.occurredAt?.toISOString(), + } + payload[NORMALIZED_EVENT_PAYLOAD_KEY] = storedEvent + return payload +} + +const readStoredBillingEvent = ( + payload: unknown, +): NormalizedBillingEvent | null => { + if (!payload || typeof payload !== 'object') return null + const stored = (payload as Record)[ + NORMALIZED_EVENT_PAYLOAD_KEY + ] as Partial | undefined + if ( + !stored || + typeof stored.eventId !== 'string' || + typeof stored.provider !== 'string' || + typeof stored.type !== 'string' || + typeof stored.customerId !== 'string' || + typeof stored.subscriptionId !== 'string' || + typeof stored.currentPeriodEnd !== 'string' || + typeof stored.readerId !== 'string' + ) { + return null + } + const currentPeriodEnd = new Date(stored.currentPeriodEnd) + const occurredAt = stored.occurredAt ? new Date(stored.occurredAt) : undefined + if ( + Number.isNaN(currentPeriodEnd.getTime()) || + (occurredAt && Number.isNaN(occurredAt.getTime())) + ) { + return null + } + return { + ...(stored as Omit< + NormalizedBillingEvent, + 'currentPeriodEnd' | 'occurredAt' + >), + currentPeriodEnd, + occurredAt, + } +} + const isLiveProviderSubscription = (row: MembershipRow): boolean => { if (row.provider === 'manual') return false if (row.status !== 'active' && row.status !== 'on_hold') return false return row.currentPeriodEnd.getTime() > Date.now() } +const hasCurrentEntitlement = (row: MembershipRow): boolean => { + const status = effectiveMembershipStatus(row) + return status === 'active' || status === 'on_hold' +} + @Injectable() export class MembershipService { constructor( @@ -31,6 +108,88 @@ export class MembershipService { return this.membershipRepository.findByReaderId(readerId) } + async getByProviderSubscriptionId( + providerSubscriptionId: string, + ): Promise { + return this.membershipRepository.findByProviderSubscriptionId( + providerSubscriptionId, + ) + } + + async confirmAppleTransaction(input: { + decoded: AppleDecodedTransaction + monthlyProductId: string + readerId: string + yearlyProductId: string + }): Promise< + | { status: 'none' } + | { + currentPeriodEnd: Date + plan: MembershipPlan + provider: MembershipProvider + status: MembershipStatus + } + > { + if ( + input.decoded.appAccountToken?.toLowerCase() !== + appleAccountTokenForReader(input.readerId) + ) { + throw createAppException( + AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + ) + } + + if (input.decoded.revocationDate !== undefined) { + throw createAppException( + AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + ) + } + + const plan = planFromAppleProductId(input.decoded.productId, { + monthlyProductId: input.monthlyProductId, + yearlyProductId: input.yearlyProductId, + }) + if (!plan) { + throw createAppException( + AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + ) + } + + const bySub = await this.membershipRepository.findByProviderSubscriptionId( + input.decoded.originalTransactionId, + ) + if (bySub && bySub.readerId !== input.readerId) { + throw createAppException(AppErrorCode.MEMBERSHIP_APPLE_ALREADY_BOUND) + } + + const byReader = await this.membershipRepository.findByReaderId( + input.readerId, + ) + if ( + byReader && + hasCurrentEntitlement(byReader) && + byReader.provider !== 'apple' + ) { + return this.toStatusResult(byReader) + } + + const event = appleActivatedEvent(input.decoded, input.readerId, plan) + await this.applyEvent({ + event, + rawPayload: input.decoded, + rawType: 'apple.confirm', + }) + await this.replayDeferredEvents( + event.provider, + event.subscriptionId, + input.readerId, + ) + + return this.toStatusResult( + await this.membershipRepository.findByReaderId(input.readerId), + ) + } + async listMembers(page: number, size: number) { return this.membershipRepository.listMembers(page, size) } @@ -43,7 +202,7 @@ export class MembershipService { provider: event.provider, eventId: event.eventId, type: rawType, - payload: rawPayload, + payload: storeBillingEventPayload(event, rawPayload), }) if (!webhookEventRow) { @@ -71,9 +230,57 @@ export class MembershipService { return { applied } } + async deferEvent(verifiedEvent: VerifiedBillingEvent): Promise { + const { event, rawPayload, rawType } = verifiedEvent + await this.billingWebhookEventRepository.create({ + provider: event.provider, + eventId: event.eventId, + type: rawType, + payload: storeBillingEventPayload(event, rawPayload), + }) + } + + private async replayDeferredEvents( + provider: string, + subscriptionId: string, + readerId: string, + ): Promise { + const rows = + await this.billingWebhookEventRepository.findPendingByProviderSubscriptionId( + provider, + subscriptionId, + ) + for (const row of rows) { + const storedEvent = readStoredBillingEvent(row.payload) + if (storedEvent) { + await this.applyMembershipState({ ...storedEvent, readerId }) + } + await this.billingWebhookEventRepository.markProcessed(row.id, new Date()) + } + } + + private async isSupersededEvent( + event: NormalizedBillingEvent, + ): Promise { + if (!event.occurredAt) return false + const latestRow = + await this.billingWebhookEventRepository.findLatestProcessedByProviderSubscriptionId( + event.provider, + event.subscriptionId, + ) + if (!latestRow) return false + const latestEvent = readStoredBillingEvent(latestRow.payload) + return Boolean( + latestEvent?.occurredAt && + latestEvent.occurredAt.getTime() >= event.occurredAt.getTime(), + ) + } + private async applyMembershipState( event: NormalizedBillingEvent, ): Promise { + if (await this.isSupersededEvent(event)) return false + let existing = await this.membershipRepository.findByProviderSubscriptionId( event.subscriptionId, ) @@ -84,9 +291,10 @@ export class MembershipService { ) if (byReader) { const canBindInitialSubscription = - byReader.provider === event.provider && - byReader.providerSubscriptionId === null && - event.type === 'activated' + event.type === 'activated' && + ((byReader.provider === event.provider && + byReader.providerSubscriptionId === null) || + !hasCurrentEntitlement(byReader)) if (!canBindInitialSubscription) return false existing = byReader } @@ -197,6 +405,23 @@ export class MembershipService { return updated! } + private toStatusResult(row: MembershipRow | null): + | { status: 'none' } + | { + currentPeriodEnd: Date + plan: MembershipPlan + provider: MembershipProvider + status: MembershipStatus + } { + if (!row) return { status: 'none' } + return { + currentPeriodEnd: row.currentPeriodEnd, + plan: row.plan, + provider: row.provider, + status: effectiveMembershipStatus(row), + } + } + private async assertReaderExists(readerId: string): Promise { const exists = await this.membershipRepository.readerExists(readerId) if (!exists) { diff --git a/apps/core/src/modules/membership/membership.types.ts b/apps/core/src/modules/membership/membership.types.ts index 5b4b172b275..d406ba48b20 100644 --- a/apps/core/src/modules/membership/membership.types.ts +++ b/apps/core/src/modules/membership/membership.types.ts @@ -1,7 +1,7 @@ import type { EntityId } from '~/shared/id/entity-id' export type MembershipProvider = - 'dodo' | 'creem' | 'lemonsqueezy' | 'stripe' | 'manual' + 'dodo' | 'creem' | 'lemonsqueezy' | 'stripe' | 'manual' | 'apple' export const REGISTERED_PAYMENT_PROVIDERS: readonly string[] = ['dodo'] @@ -59,6 +59,44 @@ export function resolveMembershipAvailability(config: { return { enabled, plans: enabled ? plans : [] } } +export interface AppleIapAvailability { + enabled: boolean + monthlyProductId?: string + yearlyProductId?: string +} + +const nonEmpty = (value?: string) => Boolean(value?.trim()) + +const positiveInteger = (value?: string) => { + const parsed = Number(value?.trim()) + return Number.isSafeInteger(parsed) && parsed > 0 +} + +export function resolveAppleIapAvailability(config: { + enabled?: boolean + appleBundleId?: string + appleAppAppleId?: string + appleKeyId?: string + appleIssuerId?: string + applePrivateKey?: string + appleMonthlyProductId?: string + appleYearlyProductId?: string +}): AppleIapAvailability { + const monthlyProductId = config.appleMonthlyProductId?.trim() + const yearlyProductId = config.appleYearlyProductId?.trim() + const enabled = + !!config.enabled && + nonEmpty(config.appleBundleId) && + positiveInteger(config.appleAppAppleId) && + nonEmpty(config.appleKeyId) && + nonEmpty(config.appleIssuerId) && + nonEmpty(config.applePrivateKey) && + nonEmpty(monthlyProductId) && + nonEmpty(yearlyProductId) + if (!enabled) return { enabled: false } + return { enabled: true, monthlyProductId, yearlyProductId } +} + export function resolveMembershipReturnUrl( returnPath: string | undefined, webUrl: string | undefined, diff --git a/apps/core/src/modules/membership/providers/apple-root-cas.ts b/apps/core/src/modules/membership/providers/apple-root-cas.ts new file mode 100644 index 00000000000..f3b60681831 --- /dev/null +++ b/apps/core/src/modules/membership/providers/apple-root-cas.ts @@ -0,0 +1,49 @@ +export const APPLE_ROOT_CA_PEMS = [ + `-----BEGIN CERTIFICATE----- +MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS +QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u +IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN +MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS +b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y +aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49 +AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf +TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517 +IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA +MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4 +at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM +6BgD56KyKA== +-----END CERTIFICATE-----`, + `-----BEGIN CERTIFICATE----- +MIIFkjCCA3qgAwIBAgIIAeDltYNno+AwDQYJKoZIhvcNAQEMBQAwZzEbMBkGA1UE +AwwSQXBwbGUgUm9vdCBDQSAtIEcyMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0 +aW9uIEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMw +HhcNMTQwNDMwMTgxMDA5WhcNMzkwNDMwMTgxMDA5WjBnMRswGQYDVQQDDBJBcHBs +ZSBSb290IENBIC0gRzIxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0 +aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBANgREkhI2imKScUcx+xuM23+TfvgHN6s +XuI2pyT5f1BrTM65MFQn5bPW7SXmMLYFN14UIhHF6Kob0vuy0gmVOKTvKkmMXT5x +ZgM4+xb1hYjkWpIMBDLyyED7Ul+f9sDx47pFoFDVEovy3d6RhiPw9bZyLgHaC/Yu +OQhfGaFjQQscp5TBhsRTL3b2CtcM0YM/GlMZ81fVJ3/8E7j4ko380yhDPLVoACVd +J2LT3VXdRCCQgzWTxb+4Gftr49wIQuavbfqeQMpOhYV4SbHXw8EwOTKrfl+q04tv +ny0aIWhwZ7Oj8ZhBbZF8+NfbqOdfIRqMM78xdLe40fTgIvS/cjTf94FNcX1RoeKz +8NMoFnNvzcytN31O661A4T+B/fc9Cj6i8b0xlilZ3MIZgIxbdMYs0xBTJh0UT8TU +gWY8h2czJxQI6bR3hDRSj4n4aJgXv8O7qhOTH11UL6jHfPsNFL4VPSQ08prcdUFm +IrQB1guvkJ4M6mL4m1k8COKWNORj3rw31OsMiANDC1CvoDTdUE0V+1ok2Az6DGOe +HwOx4e7hqkP0ZmUoNwIx7wHHHtHMn23KVDpA287PT0aLSmWaasZobNfMmRtHsHLD +d4/E92GcdB/O/WuhwpyUgquUoue9G7q5cDmVF8Up8zlYNPXEpMZ7YLlmQ1A/bmH8 +DvmGqmAMQ0uVAgMBAAGjQjBAMB0GA1UdDgQWBBTEmRNsGAPCe8CjoA1/coB6HHcm +jTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQwF +AAOCAgEAUabz4vS4PZO/Lc4Pu1vhVRROTtHlznldgX/+tvCHM/jvlOV+3Gp5pxy+ +8JS3ptEwnMgNCnWefZKVfhidfsJxaXwU6s+DDuQUQp50DhDNqxq6EWGBeNjxtUVA +eKuowM77fWM3aPbn+6/Gw0vsHzYmE1SGlHKy6gLti23kDKaQwFd1z4xCfVzmMX3z +ybKSaUYOiPjjLUKyOKimGY3xn83uamW8GrAlvacp/fQ+onVJv57byfenHmOZ4VxG +/5IFjPoeIPmGlFYl5bRXOJ3riGQUIUkhOb9iZqmxospvPyFgxYnURTbImHy99v6Z +SYA7LNKmp4gDBDEZt7Y6YUX6yfIjyGNzv1aJMbDZfGKnexWoiIqrOEDCzBL/FePw +N983csvMmOa/orz6JopxVtfnJBtIRD6e/J/JzBrsQzwBvDR4yGn1xuZW7AYJNpDr +FEobXsmII9oDMJELuDY++ee1KG++P+w8j2Ud5cAeh6Squpj9kuNsJnfdBrRkBof0 +Tta6SqoWqPQFZ2aWuuJVecMsXUmPgEkrihLHdoBR37q9ZV0+N0djMenl9MU/S60E +inpxLK8JQzcPqOMyT/RFtm2XNuyE9QoB6he7hY1Ck3DDUOUUi78/w0EP3SIEIwiK +um1xRKtzCTrJ+VKACd+66eYWyi4uTLLT3OUEVLLUNIAytbwPF+E= +-----END CERTIFICATE-----`, +] diff --git a/apps/core/src/modules/membership/providers/apple-transaction.ts b/apps/core/src/modules/membership/providers/apple-transaction.ts new file mode 100644 index 00000000000..f19fa3448f5 --- /dev/null +++ b/apps/core/src/modules/membership/providers/apple-transaction.ts @@ -0,0 +1,87 @@ +import { createHmac } from 'node:crypto' + +import { ENCRYPT, SECURITY } from '~/app.config' + +import type { MembershipPlan } from '../membership.types' +import type { NormalizedBillingEvent } from './provider.interface' + +const APPLE_ACCOUNT_TOKEN_DOMAIN = 'yohaku-membership:apple-account-token:v1\0' + +export interface AppleDecodedTransaction { + appAccountToken?: string + expiresDate: number + originalTransactionId: string + productId: string + revocationDate?: number + signedDate: number + transactionId: string +} + +export function appleAccountTokenForReader(readerId: string): string { + const digest = createHmac('sha256', SECURITY.jwtSecret || ENCRYPT.key) + .update(APPLE_ACCOUNT_TOKEN_DOMAIN, 'utf8') + .update(readerId, 'utf8') + .digest() + + // StoreKit requires a UUID. Version 8 identifies this as an application- + // defined UUID while the RFC 4122 variant keeps it universally parseable. + digest[6] = (digest[6] % 16) + 128 + digest[8] = (digest[8] % 64) + 128 + const hex = digest.subarray(0, 16).toString('hex') + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20), + ].join('-') +} + +export function planFromAppleProductId( + productId: string, + products: { monthlyProductId: string; yearlyProductId: string }, +): MembershipPlan | null { + if (productId === products.monthlyProductId) return 'monthly' + if (productId === products.yearlyProductId) return 'yearly' + return null +} + +export function appleActivatedEvent( + decoded: AppleDecodedTransaction, + readerId: string, + plan: MembershipPlan, +): NormalizedBillingEvent { + return { + eventId: decoded.transactionId, + provider: 'apple', + type: 'activated', + customerId: decoded.appAccountToken ?? decoded.originalTransactionId, + subscriptionId: decoded.originalTransactionId, + plan, + currentPeriodEnd: new Date(decoded.expiresDate), + readerId, + occurredAt: new Date(decoded.signedDate), + } +} + +const NOTIFICATION_TYPE_MAP: Record< + string, + NormalizedBillingEvent['type'] | undefined +> = { + SUBSCRIBED: 'activated', + DID_RENEW: 'renewed', + RENEWAL_EXTENDED: 'renewed', + DID_FAIL_TO_RENEW: 'on_hold', + EXPIRED: 'cancelled', + REFUND: 'cancelled', + REFUND_REVERSED: 'renewed', + REVOKE: 'cancelled', + GRACE_PERIOD_EXPIRED: 'cancelled', + DID_CHANGE_RENEWAL_PREF: 'plan_changed', +} + +export function appleNotificationEventType( + notificationType: string, +): NormalizedBillingEvent['type'] | undefined { + return NOTIFICATION_TYPE_MAP[notificationType] +} diff --git a/apps/core/src/modules/membership/providers/apple.provider.ts b/apps/core/src/modules/membership/providers/apple.provider.ts new file mode 100644 index 00000000000..ed30d1613d4 --- /dev/null +++ b/apps/core/src/modules/membership/providers/apple.provider.ts @@ -0,0 +1,328 @@ +import { + Environment, + type JWSRenewalInfoDecodedPayload, + type JWSTransactionDecodedPayload, + SignedDataVerifier, +} from '@apple/app-store-server-library' +import { Injectable, Logger } from '@nestjs/common' + +import { AppErrorCode, createAppException } from '~/common/errors' + +import { ConfigsService } from '../../configs/configs.service' +import type { MembershipPlan } from '../membership.types' +import { APPLE_ROOT_CA_PEMS } from './apple-root-cas' +import { + type AppleDecodedTransaction, + appleNotificationEventType, + planFromAppleProductId, +} from './apple-transaction' +import type { + BillingWebhookResult, + PaymentProviderAdapter, +} from './provider.interface' + +const appleRootCaBuffers = APPLE_ROOT_CA_PEMS.map((pem) => Buffer.from(pem)) + +@Injectable() +export class AppleProvider implements PaymentProviderAdapter { + private readonly logger = new Logger(AppleProvider.name) + + constructor(private readonly configsService: ConfigsService) {} + + async createCheckout(_input: { + reader: { id: string; email?: string | null; name?: string | null } + plan: MembershipPlan + returnUrl?: string + }): Promise<{ checkoutUrl: string }> { + throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) + } + + async verifySignedTransaction( + signedTransactionInfo: string, + ): Promise { + const membershipConfig = await this.configsService.get('membership') + const bundleId = membershipConfig.appleBundleId?.trim() + if (!bundleId) { + throw createAppException( + AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + ) + } + + try { + const decoded = await this.verifyTransactionWithFallback( + signedTransactionInfo, + bundleId, + membershipConfig.appleAppAppleId, + ) + if ( + !decoded.transactionId || + !decoded.originalTransactionId || + !decoded.productId || + !decoded.expiresDate || + !decoded.signedDate + ) { + throw createAppException( + AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + ) + } + return { + appAccountToken: decoded.appAccountToken, + expiresDate: decoded.expiresDate, + originalTransactionId: decoded.originalTransactionId, + productId: decoded.productId, + revocationDate: decoded.revocationDate, + signedDate: decoded.signedDate, + transactionId: decoded.transactionId, + } + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID + ) { + throw error + } + this.logger.warn( + `Apple transaction verification failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + throw createAppException( + AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + ) + } + } + + async verifyAndParseWebhook( + rawBody: Buffer | string, + _headers: Record, + ): Promise { + const membershipConfig = await this.configsService.get('membership') + const bundleId = membershipConfig.appleBundleId?.trim() + if (!bundleId) { + throw createAppException(AppErrorCode.WEBHOOK_SIGNATURE_INVALID) + } + + let signedPayload: string + try { + const parsed = JSON.parse( + typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8'), + ) as { signedPayload?: string } + if (!parsed.signedPayload) { + throw new Error('missing signedPayload') + } + signedPayload = parsed.signedPayload + } catch { + throw createAppException(AppErrorCode.WEBHOOK_SIGNATURE_INVALID) + } + + let notificationType: string + let notificationSubtype: string + let signedRenewalInfo: string | undefined + let signedTransactionInfo: string | undefined + let notificationUUID: string + let notificationSignedDate: number | undefined + try { + const notification = await this.verifyNotificationWithFallback( + signedPayload, + bundleId, + membershipConfig.appleAppAppleId, + ) + notificationType = notification.notificationType ?? '' + notificationSubtype = notification.subtype ?? '' + notificationUUID = notification.notificationUUID ?? notificationType + notificationSignedDate = notification.signedDate + signedRenewalInfo = notification.data?.signedRenewalInfo + signedTransactionInfo = notification.data?.signedTransactionInfo + } catch (error) { + this.logger.warn( + `Apple webhook verification failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + throw createAppException(AppErrorCode.WEBHOOK_SIGNATURE_INVALID) + } + + const type = appleNotificationEventType(notificationType) + if (!type) { + return { + ignored: true, + rawType: notificationType, + reason: 'unsupported_event', + } + } + + if (!signedTransactionInfo) { + return { + ignored: true, + rawType: notificationType, + reason: 'missing_reader_metadata', + } + } + + const decoded = await this.verifyTransactionWithFallback( + signedTransactionInfo, + bundleId, + membershipConfig.appleAppAppleId, + ).catch(() => null) + const occurredAt = notificationSignedDate ?? decoded?.signedDate + if ( + !decoded?.originalTransactionId || + !decoded.expiresDate || + !Number.isFinite(occurredAt) + ) { + return { + ignored: true, + rawType: notificationType, + reason: 'missing_reader_metadata', + } + } + + const monthlyProductId = membershipConfig.appleMonthlyProductId?.trim() + const yearlyProductId = membershipConfig.appleYearlyProductId?.trim() + const plan = + decoded.productId && monthlyProductId && yearlyProductId + ? (planFromAppleProductId(decoded.productId, { + monthlyProductId, + yearlyProductId, + }) ?? undefined) + : undefined + + let currentPeriodEnd = decoded.expiresDate + let renewalInfo: JWSRenewalInfoDecodedPayload | undefined + if ( + notificationType === 'DID_FAIL_TO_RENEW' && + notificationSubtype === 'GRACE_PERIOD' + ) { + renewalInfo = signedRenewalInfo + ? await this.verifyRenewalInfoWithFallback( + signedRenewalInfo, + bundleId, + membershipConfig.appleAppAppleId, + ).catch(() => undefined) + : undefined + if ( + !renewalInfo?.gracePeriodExpiresDate || + (renewalInfo.originalTransactionId && + renewalInfo.originalTransactionId !== decoded.originalTransactionId) + ) { + return { + ignored: true, + rawType: notificationType, + reason: 'missing_reader_metadata', + } + } + currentPeriodEnd = renewalInfo.gracePeriodExpiresDate + } + + return { + event: { + eventId: notificationUUID, + provider: 'apple', + type, + customerId: decoded.appAccountToken ?? decoded.originalTransactionId, + subscriptionId: decoded.originalTransactionId, + plan, + currentPeriodEnd: new Date(currentPeriodEnd), + readerId: '', + occurredAt: new Date(occurredAt!), + }, + rawType: notificationType, + rawPayload: { + notificationType, + notificationSubtype, + decoded, + renewalInfo, + }, + } + } + + private async verifyTransactionWithFallback( + signedTransactionInfo: string, + bundleId: string, + appleAppAppleId?: string, + ): Promise { + const environments = this.environmentsToTry(appleAppAppleId) + let lastError: unknown + for (const { environment, appAppleId } of environments) { + try { + const verifier = new SignedDataVerifier( + appleRootCaBuffers, + true, + environment, + bundleId, + appAppleId, + ) + return await verifier.verifyAndDecodeTransaction(signedTransactionInfo) + } catch (error) { + lastError = error + } + } + throw lastError instanceof Error ? lastError : new Error('verify failed') + } + + private async verifyNotificationWithFallback( + signedPayload: string, + bundleId: string, + appleAppAppleId?: string, + ) { + const environments = this.environmentsToTry(appleAppAppleId) + let lastError: unknown + for (const { environment, appAppleId } of environments) { + try { + const verifier = new SignedDataVerifier( + appleRootCaBuffers, + true, + environment, + bundleId, + appAppleId, + ) + return await verifier.verifyAndDecodeNotification(signedPayload) + } catch (error) { + lastError = error + } + } + throw lastError instanceof Error ? lastError : new Error('verify failed') + } + + private async verifyRenewalInfoWithFallback( + signedRenewalInfo: string, + bundleId: string, + appleAppAppleId?: string, + ): Promise { + const environments = this.environmentsToTry(appleAppAppleId) + let lastError: unknown + for (const { environment, appAppleId } of environments) { + try { + const verifier = new SignedDataVerifier( + appleRootCaBuffers, + true, + environment, + bundleId, + appAppleId, + ) + return await verifier.verifyAndDecodeRenewalInfo(signedRenewalInfo) + } catch (error) { + lastError = error + } + } + throw lastError instanceof Error ? lastError : new Error('verify failed') + } + + private environmentsToTry(appleAppAppleId?: string) { + const appAppleId = Number(appleAppAppleId) + const production = + Number.isSafeInteger(appAppleId) && appAppleId > 0 + ? [ + { + environment: Environment.PRODUCTION, + appAppleId, + }, + ] + : [] + return [ + ...production, + { environment: Environment.SANDBOX, appAppleId: undefined }, + ] + } +} diff --git a/apps/core/src/modules/membership/providers/provider.interface.ts b/apps/core/src/modules/membership/providers/provider.interface.ts index 29008c9dfa6..b61ddbeb89e 100644 --- a/apps/core/src/modules/membership/providers/provider.interface.ts +++ b/apps/core/src/modules/membership/providers/provider.interface.ts @@ -9,6 +9,7 @@ export interface NormalizedBillingEvent { plan?: MembershipPlan currentPeriodEnd: Date readerId: string + occurredAt?: Date } export interface VerifiedBillingEvent { diff --git a/apps/core/src/modules/membership/providers/provider.registry.ts b/apps/core/src/modules/membership/providers/provider.registry.ts index c7a13c32906..920bbf394e2 100644 --- a/apps/core/src/modules/membership/providers/provider.registry.ts +++ b/apps/core/src/modules/membership/providers/provider.registry.ts @@ -1,8 +1,9 @@ -import { Injectable } from '@nestjs/common' +import { Injectable, Optional } from '@nestjs/common' import { AppErrorCode, createAppException } from '~/common/errors' import type { MembershipProvider } from '../membership.types' +import { AppleProvider } from './apple.provider' import { DodoProvider } from './dodo.provider' import type { PaymentProviderAdapter } from './provider.interface' @@ -12,8 +13,12 @@ export class PaymentProviderRegistry { Record > - constructor(dodoProvider: DodoProvider) { + constructor( + dodoProvider: DodoProvider, + @Optional() appleProvider?: AppleProvider, + ) { this.registry = { dodo: dodoProvider } + if (appleProvider) this.registry.apple = appleProvider } get(provider?: string | null): PaymentProviderAdapter | undefined { diff --git a/apps/core/test/src/modules/membership/apple-transaction.spec.ts b/apps/core/test/src/modules/membership/apple-transaction.spec.ts new file mode 100644 index 00000000000..ccd5895f66c --- /dev/null +++ b/apps/core/test/src/modules/membership/apple-transaction.spec.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' + +import { + appleAccountTokenForReader, + appleActivatedEvent, + appleNotificationEventType, + planFromAppleProductId, +} from '~/modules/membership/providers/apple-transaction' + +const products = { + monthlyProductId: 'yohaku.membership.monthly', + yearlyProductId: 'yohaku.membership.yearly', +} + +describe('appleAccountTokenForReader', () => { + it('returns a stable UUID scoped to the reader account', () => { + const token = appleAccountTokenForReader('reader-1') + + expect(token).toMatch( + /^[\da-f]{8}-[\da-f]{4}-8[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/, + ) + expect(appleAccountTokenForReader('reader-1')).toBe(token) + expect(appleAccountTokenForReader('reader-2')).not.toBe(token) + }) +}) + +describe('planFromAppleProductId', () => { + it('maps configured product ids', () => { + expect(planFromAppleProductId('yohaku.membership.monthly', products)).toBe( + 'monthly', + ) + expect(planFromAppleProductId('yohaku.membership.yearly', products)).toBe( + 'yearly', + ) + }) + + it('returns null for an unknown product', () => { + expect(planFromAppleProductId('other.sku', products)).toBeNull() + }) +}) + +describe('appleActivatedEvent', () => { + it('uses originalTransactionId as the subscription key', () => { + const event = appleActivatedEvent( + { + expiresDate: Date.parse('2026-09-01T00:00:00.000Z'), + originalTransactionId: 'orig-1', + productId: 'yohaku.membership.yearly', + signedDate: Date.parse('2026-08-01T00:00:00.000Z'), + transactionId: 'txn-1', + }, + 'reader-1', + 'yearly', + ) + expect(event).toMatchObject({ + eventId: 'txn-1', + provider: 'apple', + type: 'activated', + customerId: 'orig-1', + subscriptionId: 'orig-1', + plan: 'yearly', + readerId: 'reader-1', + }) + expect(event.currentPeriodEnd.toISOString()).toBe( + '2026-09-01T00:00:00.000Z', + ) + expect(event.occurredAt?.toISOString()).toBe('2026-08-01T00:00:00.000Z') + }) +}) + +describe('appleNotificationEventType', () => { + it('maps App Store notification names', () => { + expect(appleNotificationEventType('DID_RENEW')).toBe('renewed') + expect(appleNotificationEventType('EXPIRED')).toBe('cancelled') + expect(appleNotificationEventType('REFUND')).toBe('cancelled') + expect(appleNotificationEventType('REFUND_REVERSED')).toBe('renewed') + expect(appleNotificationEventType('TEST')).toBeUndefined() + }) +}) diff --git a/apps/core/test/src/modules/membership/apple.provider.spec.ts b/apps/core/test/src/modules/membership/apple.provider.spec.ts new file mode 100644 index 00000000000..a5569763491 --- /dev/null +++ b/apps/core/test/src/modules/membership/apple.provider.spec.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { AppErrorCode } from '~/common/errors' +import { AppleProvider } from '~/modules/membership/providers/apple.provider' + +const get = vi.fn() + +describe('AppleProvider', () => { + beforeEach(() => { + get.mockReset() + }) + + it('refuses web checkout', async () => { + const provider = new AppleProvider({ get } as any) + await expect( + provider.createCheckout({ + reader: { id: 'reader-1' }, + plan: 'monthly', + }), + ).rejects.toMatchObject({ + code: AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED, + }) + }) + + it('preserves revocation metadata from a verified transaction', async () => { + get.mockResolvedValue({ + appleAppAppleId: '1234567890', + appleBundleId: 'dev.yohaku.app', + }) + const provider = new AppleProvider({ get } as any) + vi.spyOn( + provider as any, + 'verifyTransactionWithFallback', + ).mockResolvedValue({ + expiresDate: Date.parse('2026-09-01T00:00:00.000Z'), + originalTransactionId: 'original-1', + productId: 'yohaku.membership.monthly', + revocationDate: Date.parse('2026-08-21T00:00:00.000Z'), + signedDate: Date.parse('2026-08-01T00:00:00.000Z'), + transactionId: 'transaction-1', + }) + + await expect( + provider.verifySignedTransaction('signed-transaction'), + ).resolves.toMatchObject({ + revocationDate: Date.parse('2026-08-21T00:00:00.000Z'), + }) + }) + + it.each([ + ['DID_RENEW', 'renewed'], + ['RENEWAL_EXTENDED', 'renewed'], + ['REFUND_REVERSED', 'renewed'], + ['DID_CHANGE_RENEWAL_PREF', 'plan_changed'], + ] as const)( + 'maps the current Apple product during %s', + async (notificationType, expectedType) => { + get.mockResolvedValue({ + appleAppAppleId: '1234567890', + appleBundleId: 'dev.yohaku.app', + appleMonthlyProductId: 'yohaku.membership.monthly', + appleYearlyProductId: 'yohaku.membership.yearly', + }) + const provider = new AppleProvider({ get } as any) + vi.spyOn( + provider as any, + 'verifyNotificationWithFallback', + ).mockResolvedValue({ + data: { signedTransactionInfo: 'signed-transaction' }, + notificationType, + notificationUUID: 'notification-1', + signedDate: Date.parse('2026-08-21T00:00:00.000Z'), + }) + vi.spyOn( + provider as any, + 'verifyTransactionWithFallback', + ).mockResolvedValue({ + expiresDate: Date.parse('2026-09-01T00:00:00.000Z'), + originalTransactionId: 'original-1', + productId: 'yohaku.membership.yearly', + }) + + await expect( + provider.verifyAndParseWebhook( + JSON.stringify({ signedPayload: 'notification-jws' }), + {}, + ), + ).resolves.toMatchObject({ + event: { plan: 'yearly', type: expectedType }, + }) + }, + ) + + it('uses the signed renewal grace-period deadline after a failed renewal', async () => { + get.mockResolvedValue({ + appleAppAppleId: '1234567890', + appleBundleId: 'dev.yohaku.app', + appleMonthlyProductId: 'yohaku.membership.monthly', + appleYearlyProductId: 'yohaku.membership.yearly', + }) + const provider = new AppleProvider({ get } as any) + vi.spyOn( + provider as any, + 'verifyNotificationWithFallback', + ).mockResolvedValue({ + data: { + signedRenewalInfo: 'signed-renewal', + signedTransactionInfo: 'signed-transaction', + }, + notificationType: 'DID_FAIL_TO_RENEW', + notificationUUID: 'notification-1', + signedDate: Date.parse('2026-08-21T00:00:00.000Z'), + subtype: 'GRACE_PERIOD', + }) + vi.spyOn( + provider as any, + 'verifyTransactionWithFallback', + ).mockResolvedValue({ + expiresDate: Date.parse('2026-08-20T00:00:00.000Z'), + originalTransactionId: 'original-1', + productId: 'yohaku.membership.monthly', + }) + vi.spyOn( + provider as any, + 'verifyRenewalInfoWithFallback', + ).mockResolvedValue({ + gracePeriodExpiresDate: Date.parse('2026-08-27T00:00:00.000Z'), + originalTransactionId: 'original-1', + }) + + const result = await provider.verifyAndParseWebhook( + JSON.stringify({ signedPayload: 'notification-jws' }), + {}, + ) + + expect(result).toMatchObject({ + event: { + currentPeriodEnd: new Date('2026-08-27T00:00:00.000Z'), + occurredAt: new Date('2026-08-21T00:00:00.000Z'), + type: 'on_hold', + }, + }) + }) +}) diff --git a/apps/core/test/src/modules/membership/entitlement.service.spec.ts b/apps/core/test/src/modules/membership/entitlement.service.spec.ts index 8a7f1cbb433..801851bba9c 100644 --- a/apps/core/test/src/modules/membership/entitlement.service.spec.ts +++ b/apps/core/test/src/modules/membership/entitlement.service.spec.ts @@ -177,6 +177,30 @@ describe('EntitlementService.getAvailability', () => { }) }) + it('locks premium content when Apple IAP is the only purchase path', async () => { + const { service, membershipRepository } = createService({ + enabled: true, + appleAppAppleId: '1234567890', + appleBundleId: 'dev.yohaku.app', + appleIssuerId: 'ISSUER', + appleKeyId: 'KEYID', + appleMonthlyProductId: 'yohaku.membership.monthly', + applePrivateKey: + '-----BEGIN PRIVATE KEY-----\nX\n-----END PRIVATE KEY-----', + appleYearlyProductId: 'yohaku.membership.yearly', + }) + membershipRepository.findByReaderId.mockResolvedValue(null) + + expect(await service.isMembershipPurchasable()).toBe(true) + expect( + await service.isPremiumLocked({ + isOwner: false, + isPremium: true, + readerId: 'reader-1', + }), + ).toBe(true) + }) + it('is not purchasable when disabled', async () => { const { service } = createService({ enabled: false, diff --git a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts index 621163375dd..e9129baa0aa 100644 --- a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts @@ -22,6 +22,8 @@ import { EntitlementService } from '~/modules/membership/entitlement.service' import { MembershipController } from '~/modules/membership/membership.controller' import { MembershipRepository } from '~/modules/membership/membership.repository' import { MembershipService } from '~/modules/membership/membership.service' +import { AppleProvider } from '~/modules/membership/providers/apple.provider' +import { appleAccountTokenForReader } from '~/modules/membership/providers/apple-transaction' import { DodoProvider } from '~/modules/membership/providers/dodo.provider' import { PaymentProviderRegistry } from '~/modules/membership/providers/provider.registry' import type { AppDatabase } from '~/processors/database/postgres.provider' @@ -37,6 +39,7 @@ const liveSubReaderId = snowflake.nextId() const expiredReaderId = snowflake.nextId() const checkoutActiveReaderId = snowflake.nextId() const checkoutExpiredReaderId = snowflake.nextId() +const appleConfirmReaderId = snowflake.nextId() const readerUser = { id: readerId, @@ -66,6 +69,13 @@ const checkoutExpiredReaderUser = { role: 'reader' as const, } +const appleConfirmReaderUser = { + id: appleConfirmReaderId, + email: 'apple-confirm@example.com', + name: 'Reader Apple Confirm', + role: 'reader' as const, +} + const membershipConfig: { enabled: boolean provider: string | undefined @@ -113,6 +123,12 @@ const authServiceMock = { session: { token: 'checkout-expired-token' }, } } + if (header === 'apple-confirm') { + return { + user: appleConfirmReaderUser, + session: { token: 'apple-confirm-token' }, + } + } return null }), } @@ -166,6 +182,12 @@ const dodoProviderMock = { getPlanPricing: getPlanPricingMock, } +const verifySignedTransactionMock = vi.fn() + +const appleProviderMock = { + verifySignedTransaction: verifySignedTransactionMock, +} + const membershipModule: ModuleMetadata = { controllers: [MembershipController], providers: [ @@ -175,6 +197,7 @@ const membershipModule: ModuleMetadata = { EntitlementService, { provide: SnowflakeService, useValue: snowflake }, { provide: DodoProvider, useValue: dodoProviderMock }, + { provide: AppleProvider, useValue: appleProviderMock }, PaymentProviderRegistry, { provide: AuthService, useValue: authServiceMock }, { provide: ConfigsService, useValue: configsServiceMock }, @@ -201,6 +224,7 @@ beforeAll(async () => { { id: expiredReaderId, name: 'Reader Expired', role: 'reader' }, { id: checkoutActiveReaderId, name: 'Checkout Active', role: 'reader' }, { id: checkoutExpiredReaderId, name: 'Checkout Expired', role: 'reader' }, + { id: appleConfirmReaderId, name: 'Apple Confirm', role: 'reader' }, ]) }, 120_000) @@ -217,9 +241,19 @@ describe('MembershipController (e2e)', () => { membershipConfig.enabled = true membershipConfig.provider = 'dodo' membershipConfig.webhookSigningKey = 'webhook-key' + delete (membershipConfig as { appleAppAppleId?: string }).appleAppAppleId + delete (membershipConfig as { appleBundleId?: string }).appleBundleId + delete (membershipConfig as { appleKeyId?: string }).appleKeyId + delete (membershipConfig as { appleIssuerId?: string }).appleIssuerId + delete (membershipConfig as { applePrivateKey?: string }).applePrivateKey + delete (membershipConfig as { appleMonthlyProductId?: string }) + .appleMonthlyProductId + delete (membershipConfig as { appleYearlyProductId?: string }) + .appleYearlyProductId urlConfig.webUrl = undefined createCheckoutMock.mockClear() verifyAndParseWebhookMock.mockClear() + verifySignedTransactionMock.mockReset() }) describe('GET /membership/config-status', () => { @@ -234,6 +268,7 @@ describe('MembershipController (e2e)', () => { expect(res.json()).toEqual({ data: { api_key_configured: true, + apple_private_key_configured: false, supported_providers: ['dodo'], webhook_signing_key_configured: true, }, @@ -439,6 +474,7 @@ describe('MembershipController (e2e)', () => { expect(res.statusCode).toBe(200) expect(res.json()).toEqual({ data: { + apple_iap: { enabled: false }, enabled: true, plans: [ { @@ -487,7 +523,136 @@ describe('MembershipController (e2e)', () => { }) expect(res.statusCode).toBe(200) - expect(res.json()).toEqual({ data: { enabled: false, plans: [] } }) + expect(res.json()).toEqual({ + data: { apple_iap: { enabled: false }, enabled: false, plans: [] }, + }) + }) + + it('reports appleIap when Apple fields are configured', async () => { + Object.assign(membershipConfig, { + appleAppAppleId: '1234567890', + appleBundleId: 'dev.yohaku.app', + appleKeyId: 'KEYID', + appleIssuerId: 'ISSUER', + applePrivateKey: + '-----BEGIN PRIVATE KEY-----\\nX\\n-----END PRIVATE KEY-----', + appleMonthlyProductId: 'yohaku.membership.monthly', + appleYearlyProductId: 'yohaku.membership.yearly', + }) + + const res = await proxy.app.inject({ + method: 'GET', + url: '/membership/plans', + }) + + expect(res.statusCode).toBe(200) + expect(res.json().data.apple_iap).toEqual({ + enabled: true, + monthly_product_id: 'yohaku.membership.monthly', + yearly_product_id: 'yohaku.membership.yearly', + }) + }) + }) + + describe('GET /membership/apple/account-token', () => { + it('returns the authenticated reader token', async () => { + const res = await proxy.app.inject({ + method: 'GET', + url: '/membership/apple/account-token', + headers: { 'x-test-reader': 'apple-confirm' }, + }) + + expect(res.statusCode).toBe(200) + expect(res.json().data).toEqual({ + account_token: appleAccountTokenForReader(appleConfirmReaderId), + }) + }) + + it('returns 401 without a reader session', async () => { + const res = await proxy.app.inject({ + method: 'GET', + url: '/membership/apple/account-token', + }) + expect(res.statusCode).toBe(401) + }) + }) + + describe('POST /membership/apple/confirm', () => { + it('returns the new apple membership for a signed-in reader', async () => { + Object.assign(membershipConfig, { + appleAppAppleId: '1234567890', + appleBundleId: 'dev.yohaku.app', + appleKeyId: 'KEYID', + appleIssuerId: 'ISSUER', + applePrivateKey: + '-----BEGIN PRIVATE KEY-----\\nX\\n-----END PRIVATE KEY-----', + appleMonthlyProductId: 'yohaku.membership.monthly', + appleYearlyProductId: 'yohaku.membership.yearly', + }) + verifySignedTransactionMock.mockResolvedValueOnce({ + appAccountToken: appleAccountTokenForReader(appleConfirmReaderId), + expiresDate: Date.now() + 86_400_000, + originalTransactionId: 'orig-e2e', + productId: 'yohaku.membership.monthly', + signedDate: Date.now(), + transactionId: 'txn-e2e', + }) + + const res = await proxy.app.inject({ + method: 'POST', + url: '/membership/apple/confirm', + headers: { 'x-test-reader': 'apple-confirm' }, + payload: { signedTransactionInfo: 'jws' }, + }) + + expect(res.statusCode).toBe(200) + expect(res.json().data).toMatchObject({ + plan: 'monthly', + provider: 'apple', + status: 'active', + }) + }) + + it('rejects a signed transaction owned by another reader', async () => { + Object.assign(membershipConfig, { + appleAppAppleId: '1234567890', + appleBundleId: 'dev.yohaku.app', + appleKeyId: 'KEYID', + appleIssuerId: 'ISSUER', + applePrivateKey: + '-----BEGIN PRIVATE KEY-----\\nX\\n-----END PRIVATE KEY-----', + appleMonthlyProductId: 'yohaku.membership.monthly', + appleYearlyProductId: 'yohaku.membership.yearly', + }) + verifySignedTransactionMock.mockResolvedValueOnce({ + appAccountToken: appleAccountTokenForReader(otherReaderId), + expiresDate: Date.now() + 86_400_000, + originalTransactionId: 'orig-other-reader', + productId: 'yohaku.membership.monthly', + signedDate: Date.now(), + transactionId: 'txn-other-reader', + }) + + const res = await proxy.app.inject({ + method: 'POST', + url: '/membership/apple/confirm', + headers: { 'x-test-reader': 'apple-confirm' }, + payload: { signedTransactionInfo: 'jws' }, + }) + + expect(res.statusCode).toBe(400) + expect(res.json()).toMatchObject({ + error: { code: AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID }, + }) + }) + + it('returns 401 without a reader session', async () => { + const res = await proxy.app.inject({ + method: 'POST', + url: '/membership/apple/confirm', + payload: { signedTransactionInfo: 'jws' }, + }) + expect(res.statusCode).toBe(401) }) }) diff --git a/apps/core/test/src/modules/membership/membership.service.spec.ts b/apps/core/test/src/modules/membership/membership.service.spec.ts index 9f9a57131b4..8712e143445 100644 --- a/apps/core/test/src/modules/membership/membership.service.spec.ts +++ b/apps/core/test/src/modules/membership/membership.service.spec.ts @@ -1,10 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createPgRepositoryMock, now } from '@/helper/pg-repository-mock' +import { AppErrorCode } from '~/common/errors' import type { BillingWebhookEventRepository } from '~/modules/membership/billing-webhook-event.repository' import type { MembershipRepository } from '~/modules/membership/membership.repository' import { MembershipService } from '~/modules/membership/membership.service' import type { MembershipRow } from '~/modules/membership/membership.types' +import { appleAccountTokenForReader } from '~/modules/membership/providers/apple-transaction' import type { NormalizedBillingEvent, VerifiedBillingEvent, @@ -57,6 +59,12 @@ const createService = () => { membershipRepository.findByReaderId.mockResolvedValue(null) membershipRepository.readerExists.mockResolvedValue(true) billingWebhookEventRepository.findByProviderAndEventId.mockResolvedValue(null) + billingWebhookEventRepository.findLatestProcessedByProviderSubscriptionId.mockResolvedValue( + null, + ) + billingWebhookEventRepository.findPendingByProviderSubscriptionId.mockResolvedValue( + [], + ) billingWebhookEventRepository.create.mockResolvedValue({ id: 'event-1' as any, provider: 'dodo', @@ -486,4 +494,365 @@ describe('MembershipService', () => { expect(membershipRepository.update).not.toHaveBeenCalled() }) }) + + describe('confirmAppleTransaction', () => { + const decoded = { + appAccountToken: appleAccountTokenForReader('reader-1'), + expiresDate: now.getTime() + 86_400_000, + originalTransactionId: 'orig-apple', + productId: 'yohaku.membership.monthly', + signedDate: now.getTime(), + transactionId: 'txn-apple', + } + const products = { + monthlyProductId: 'yohaku.membership.monthly', + yearlyProductId: 'yohaku.membership.yearly', + } + + it('creates an apple membership for a new reader', async () => { + const { service, membershipRepository } = createService() + const created = createMembership({ + provider: 'apple', + providerSubscriptionId: 'orig-apple', + readerId: 'reader-1', + }) + membershipRepository.create.mockResolvedValue(created) + membershipRepository.findByReaderId + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(created) + + const result = await service.confirmAppleTransaction({ + decoded, + readerId: 'reader-1', + ...products, + }) + + expect(membershipRepository.create).toHaveBeenCalled() + expect(result).toMatchObject({ + status: 'active', + plan: 'monthly', + provider: 'apple', + }) + }) + + it('returns the existing live Dodo membership without rewriting it', async () => { + const { service, membershipRepository } = createService() + const live = createMembership({ provider: 'dodo' }) + membershipRepository.findByReaderId.mockResolvedValue(live) + + const result = await service.confirmAppleTransaction({ + decoded, + readerId: 'reader-1', + ...products, + }) + + expect(membershipRepository.create).not.toHaveBeenCalled() + expect(membershipRepository.update).not.toHaveBeenCalled() + expect(result).toMatchObject({ provider: 'dodo', status: 'active' }) + }) + + it('returns an existing active manual membership without rewriting it', async () => { + const { service, membershipRepository } = createService() + const live = createMembership({ + provider: 'manual', + providerCustomerId: null, + providerSubscriptionId: null, + }) + membershipRepository.findByReaderId.mockResolvedValue(live) + + const result = await service.confirmAppleTransaction({ + decoded, + readerId: 'reader-1', + ...products, + }) + + expect(membershipRepository.create).not.toHaveBeenCalled() + expect(membershipRepository.update).not.toHaveBeenCalled() + expect(result).toMatchObject({ provider: 'manual', status: 'active' }) + }) + + it('rebinds an inactive non-Apple row to the confirmed Apple subscription', async () => { + const { service, membershipRepository } = createService() + const inactive = createMembership({ + provider: 'dodo', + status: 'cancelled', + currentPeriodEnd: new Date(now.getTime() - 1_000), + }) + const rebound = createMembership({ + provider: 'apple', + providerCustomerId: 'orig-apple', + providerSubscriptionId: 'orig-apple', + plan: 'monthly', + status: 'active', + currentPeriodEnd: new Date(decoded.expiresDate), + }) + membershipRepository.findByReaderId + .mockResolvedValueOnce(inactive) + .mockResolvedValueOnce(inactive) + .mockResolvedValueOnce(rebound) + + const result = await service.confirmAppleTransaction({ + decoded, + readerId: 'reader-1', + ...products, + }) + + expect(membershipRepository.update).toHaveBeenCalledWith( + inactive.id, + expect.objectContaining({ + plan: 'monthly', + provider: 'apple', + providerSubscriptionId: 'orig-apple', + status: 'active', + }), + ) + expect(result).toMatchObject({ provider: 'apple', status: 'active' }) + }) + + it('does not reactivate a cancelled Apple membership when confirmation is replayed', async () => { + const { service, membershipRepository, billingWebhookEventRepository } = + createService() + const cancelled = createMembership({ + provider: 'apple', + providerCustomerId: 'orig-apple', + providerSubscriptionId: 'orig-apple', + status: 'cancelled', + }) + membershipRepository.findByProviderSubscriptionId.mockResolvedValue( + cancelled, + ) + membershipRepository.findByReaderId.mockResolvedValue(cancelled) + billingWebhookEventRepository.create.mockResolvedValue(null) + billingWebhookEventRepository.findByProviderAndEventId.mockResolvedValue({ + id: 'event-apple' as any, + provider: 'apple', + eventId: decoded.transactionId, + type: 'apple.confirm', + payload: decoded, + processedAt: now, + receivedAt: now, + }) + + const result = await service.confirmAppleTransaction({ + decoded, + readerId: 'reader-1', + ...products, + }) + + expect(membershipRepository.update).not.toHaveBeenCalled() + expect(result).toMatchObject({ provider: 'apple', status: 'cancelled' }) + }) + + it('rejects when the originalTransactionId is bound to another reader', async () => { + const { service, membershipRepository } = createService() + membershipRepository.findByProviderSubscriptionId.mockResolvedValue( + createMembership({ readerId: 'other-reader', provider: 'apple' }), + ) + + await expect( + service.confirmAppleTransaction({ + decoded, + readerId: 'reader-1', + ...products, + }), + ).rejects.toMatchObject({ + code: AppErrorCode.MEMBERSHIP_APPLE_ALREADY_BOUND, + }) + }) + + it('rejects an unknown product id', async () => { + const { service } = createService() + await expect( + service.confirmAppleTransaction({ + decoded: { ...decoded, productId: 'unknown.sku' }, + readerId: 'reader-1', + ...products, + }), + ).rejects.toMatchObject({ + code: AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + }) + }) + + it('rejects a transaction purchased for another reader account', async () => { + const { service, membershipRepository, billingWebhookEventRepository } = + createService() + + await expect( + service.confirmAppleTransaction({ + decoded: { + ...decoded, + appAccountToken: appleAccountTokenForReader('reader-2'), + }, + readerId: 'reader-1', + ...products, + }), + ).rejects.toMatchObject({ + code: AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + }) + + expect(membershipRepository.create).not.toHaveBeenCalled() + expect(membershipRepository.update).not.toHaveBeenCalled() + expect(billingWebhookEventRepository.create).not.toHaveBeenCalled() + }) + + it('rejects a transaction without an account token', async () => { + const { service, membershipRepository } = createService() + + await expect( + service.confirmAppleTransaction({ + decoded: { ...decoded, appAccountToken: undefined }, + readerId: 'reader-1', + ...products, + }), + ).rejects.toMatchObject({ + code: AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + }) + + expect(membershipRepository.create).not.toHaveBeenCalled() + expect(membershipRepository.update).not.toHaveBeenCalled() + }) + + it('rejects a revoked Apple transaction before granting membership', async () => { + const { service, membershipRepository, billingWebhookEventRepository } = + createService() + + await expect( + service.confirmAppleTransaction({ + decoded: { ...decoded, revocationDate: now.getTime() }, + readerId: 'reader-1', + ...products, + }), + ).rejects.toMatchObject({ + code: AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + }) + + expect(membershipRepository.create).not.toHaveBeenCalled() + expect(membershipRepository.update).not.toHaveBeenCalled() + expect(billingWebhookEventRepository.create).not.toHaveBeenCalled() + }) + + it('replays a newer deferred refund before returning confirmed status', async () => { + const { service, membershipRepository, billingWebhookEventRepository } = + createService() + const active = createMembership({ + provider: 'apple', + providerCustomerId: decoded.appAccountToken, + providerSubscriptionId: decoded.originalTransactionId, + status: 'active', + }) + const cancelled = createMembership({ + ...active, + status: 'cancelled', + }) + const refundOccurredAt = new Date(decoded.signedDate + 1_000) + const refund = createEvent({ + customerId: decoded.appAccountToken, + eventId: 'refund-before-confirm', + provider: 'apple', + readerId: '', + subscriptionId: decoded.originalTransactionId, + type: 'cancelled', + occurredAt: refundOccurredAt, + }) + const pendingRefund = { + id: 'pending-refund' as any, + provider: 'apple', + eventId: refund.event.eventId, + type: 'REFUND', + payload: { + _normalizedMembershipEvent: { + ...refund.event, + currentPeriodEnd: refund.event.currentPeriodEnd.toISOString(), + occurredAt: refundOccurredAt.toISOString(), + }, + }, + processedAt: null, + receivedAt: refundOccurredAt, + } + + await service.deferEvent(refund) + billingWebhookEventRepository.findPendingByProviderSubscriptionId.mockResolvedValue( + [pendingRefund], + ) + membershipRepository.findByProviderSubscriptionId + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(active) + membershipRepository.findByReaderId + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(cancelled) + membershipRepository.create.mockResolvedValue(active) + membershipRepository.update.mockResolvedValue(cancelled) + + const result = await service.confirmAppleTransaction({ + decoded, + readerId: 'reader-1', + ...products, + }) + + expect(billingWebhookEventRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: 'refund-before-confirm', + payload: expect.objectContaining({ + _normalizedMembershipEvent: expect.objectContaining({ + occurredAt: refundOccurredAt.toISOString(), + subscriptionId: decoded.originalTransactionId, + type: 'cancelled', + }), + }), + }), + ) + expect(membershipRepository.update).toHaveBeenCalledWith( + active.id, + expect.objectContaining({ status: 'cancelled' }), + ) + expect(result).toMatchObject({ provider: 'apple', status: 'cancelled' }) + }) + }) + + it('does not apply an Apple renewal older than the latest processed refund', async () => { + const { service, membershipRepository, billingWebhookEventRepository } = + createService() + const refundOccurredAt = new Date(now.getTime() - 1_000) + billingWebhookEventRepository.findLatestProcessedByProviderSubscriptionId.mockResolvedValue( + { + id: 'latest-refund' as any, + provider: 'apple', + eventId: 'refund-latest', + type: 'REFUND', + payload: { + _normalizedMembershipEvent: { + eventId: 'refund-latest', + provider: 'apple', + type: 'cancelled', + customerId: 'customer-apple', + subscriptionId: 'subscription-apple', + currentPeriodEnd: now.toISOString(), + readerId: 'reader-1', + occurredAt: refundOccurredAt.toISOString(), + }, + }, + processedAt: refundOccurredAt, + receivedAt: refundOccurredAt, + }, + ) + + const result = await service.applyEvent( + createEvent({ + eventId: 'renewal-delayed', + provider: 'apple', + type: 'renewed', + customerId: 'customer-apple', + subscriptionId: 'subscription-apple', + occurredAt: new Date(refundOccurredAt.getTime() - 1_000), + }), + ) + + expect(result).toEqual({ applied: false }) + expect(membershipRepository.create).not.toHaveBeenCalled() + expect(membershipRepository.update).not.toHaveBeenCalled() + expect(billingWebhookEventRepository.markProcessed).toHaveBeenCalled() + }) }) diff --git a/apps/core/test/src/modules/membership/membership.types.spec.ts b/apps/core/test/src/modules/membership/membership.types.spec.ts index 873d061d83e..c5899d15e96 100644 --- a/apps/core/test/src/modules/membership/membership.types.spec.ts +++ b/apps/core/test/src/modules/membership/membership.types.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { + resolveAppleIapAvailability, resolveMembershipAvailability, resolveMembershipReturnUrl, } from '~/modules/membership/membership.types' @@ -115,3 +116,54 @@ describe('resolveMembershipReturnUrl', () => { ).toBeUndefined() }) }) + +const appleCredentials = { + appleAppAppleId: '1234567890', + appleBundleId: 'dev.yohaku.app', + appleKeyId: 'KEYID', + appleIssuerId: 'ISSUER', + applePrivateKey: '-----BEGIN PRIVATE KEY-----\nX\n-----END PRIVATE KEY-----', + appleMonthlyProductId: 'yohaku.membership.monthly', + appleYearlyProductId: 'yohaku.membership.yearly', +} + +describe('resolveAppleIapAvailability', () => { + it('is enabled when the master switch and all Apple fields are set', () => { + expect( + resolveAppleIapAvailability({ enabled: true, ...appleCredentials }), + ).toEqual({ + enabled: true, + monthlyProductId: 'yohaku.membership.monthly', + yearlyProductId: 'yohaku.membership.yearly', + }) + }) + + it('is disabled when the master switch is off', () => { + expect( + resolveAppleIapAvailability({ enabled: false, ...appleCredentials }), + ).toEqual({ enabled: false }) + }) + + it('is disabled when any Apple field is missing', () => { + expect( + resolveAppleIapAvailability({ + enabled: true, + ...appleCredentials, + appleKeyId: '', + }), + ).toEqual({ enabled: false }) + }) + + it.each([undefined, '', 'not-a-number', '1.5', '0'])( + 'is disabled when the App Apple ID is not a positive integer (%s)', + (appleAppAppleId) => { + expect( + resolveAppleIapAvailability({ + enabled: true, + ...appleCredentials, + appleAppAppleId, + }), + ).toEqual({ enabled: false }) + }, + ) +}) diff --git a/packages/api-client/__tests__/controllers/membership.test.ts b/packages/api-client/__tests__/controllers/membership.test.ts index f85ef15aded..fe5111f8c7e 100644 --- a/packages/api-client/__tests__/controllers/membership.test.ts +++ b/packages/api-client/__tests__/controllers/membership.test.ts @@ -55,4 +55,25 @@ describe('test Membership client', () => { const data = await client.membership.status() expect(data).toEqual({ status: 'none' }) }) + + test('POST /membership/apple/confirm', async () => { + mockResponse( + '/membership/apple/confirm', + { + status: 'active', + plan: 'monthly', + provider: 'apple', + current_period_end: '2027-08-20T00:00:00.000Z', + }, + 'post', + { signedTransactionInfo: 'jws' }, + ) + + const data = await client.membership.confirmApple('jws') + expect(data).toMatchObject({ + status: 'active', + plan: 'monthly', + provider: 'apple', + }) + }) }) diff --git a/packages/api-client/controllers/membership.ts b/packages/api-client/controllers/membership.ts index 0d410d4055a..9abea0f9b22 100644 --- a/packages/api-client/controllers/membership.ts +++ b/packages/api-client/controllers/membership.ts @@ -45,4 +45,10 @@ export class MembershipController implements IController { plans() { return this.proxy.plans.get() } + + confirmApple(signedTransactionInfo: string) { + return this.proxy.apple.confirm.post({ + data: { signedTransactionInfo }, + }) + } } diff --git a/packages/api-client/models/membership.ts b/packages/api-client/models/membership.ts index cd4cc370d32..6493913eb41 100644 --- a/packages/api-client/models/membership.ts +++ b/packages/api-client/models/membership.ts @@ -1,5 +1,5 @@ export type MembershipProvider = - 'dodo' | 'creem' | 'lemonsqueezy' | 'stripe' | 'manual' + 'dodo' | 'creem' | 'lemonsqueezy' | 'stripe' | 'manual' | 'apple' export type MembershipPlan = 'monthly' | 'yearly' @@ -21,7 +21,14 @@ export interface MembershipPlanInfo { pricing?: MembershipPlanPricing } +export interface MembershipAppleIap { + enabled: boolean + monthlyProductId?: string + yearlyProductId?: string +} + export interface MembershipPlansResult { + appleIap: MembershipAppleIap enabled: boolean plans: MembershipPlanInfo[] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc93960fd79..2f77e91a4d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,6 +536,9 @@ importers: '@antfu/install-pkg': specifier: 2.0.1 version: 2.0.1 + '@apple/app-store-server-library': + specifier: ^3.1.0 + version: 3.1.0(@noble/hashes@2.3.0)(encoding@0.1.13) '@babel/core': specifier: 8.0.1 version: 8.0.1 @@ -1440,6 +1443,9 @@ packages: '@antv/vendor@1.0.11': resolution: {integrity: sha512-LmhPEQ+aapk3barntaiIxJ5VHno/Tyab2JnfdcPzp5xONh/8VSfed4bo/9xKo5HcUAEydko38vYLfj6lJliLiw==} + '@apple/app-store-server-library@3.1.0': + resolution: {integrity: sha512-d26SICRz8BwCV2qPR0BSXBMxmw0NEvJLwsdREcBmCrus8NmyHw2XgOOP0fiFjcctPn/JXtmSDuGVnaHe+dqO+A==} + '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} @@ -6085,6 +6091,9 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/jsrsasign@10.5.15': + resolution: {integrity: sha512-3stUTaSRtN09PPzVWR6aySD9gNnuymz+WviNHoTb85dKu+BjaV4uBbWWGykBBJkfwPtcNZVfTn2lbX00U+yhpQ==} + '@types/luxon@3.6.2': resolution: {integrity: sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==} @@ -6100,9 +6109,15 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@25.9.5': + resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} + '@types/node@26.2.0': resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} @@ -6962,6 +6977,10 @@ packages: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} + base64url@3.0.1: + resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} + engines: {node: '>=6.0.0'} + baseline-browser-mapping@2.10.38: resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} engines: {node: '>=6.0.0'} @@ -9449,6 +9468,10 @@ packages: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} + jsrsasign@11.1.5: + resolution: {integrity: sha512-i6mAjey0/4UQmlOaNS2vROl8EkWV8Ei436reJAby2OPQ3lF6uXSs1VYpnM8hndmmCMFIzNc04Uw2GF+0JVLFbg==} + deprecated: This package is no longer maintained. + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -11974,6 +11997,9 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -12847,6 +12873,20 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + '@apple/app-store-server-library@3.1.0(@noble/hashes@2.3.0)(encoding@0.1.13)': + dependencies: + '@types/jsonwebtoken': 9.0.10 + '@types/jsrsasign': 10.5.15 + '@types/node': 25.9.5 + '@types/node-fetch': 2.6.13 + base64url: 3.0.1 + jsonwebtoken: 9.0.3 + jsrsasign: 11.1.5 + node-fetch: 2.7.0(@noble/hashes@2.3.0)(encoding@0.1.13) + transitivePeerDependencies: + - '@noble/hashes' + - encoding + '@aws-crypto/sha256-browser@5.2.0': dependencies: '@aws-crypto/sha256-js': 5.2.0 @@ -17933,6 +17973,8 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 26.2.0 + '@types/jsrsasign@10.5.15': {} + '@types/luxon@3.6.2': {} '@types/luxon@3.7.2': {} @@ -17945,10 +17987,19 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 26.2.0 + form-data: 4.0.6 + '@types/node@18.19.130': dependencies: undici-types: 5.26.5 + '@types/node@25.9.5': + dependencies: + undici-types: 7.24.6 + '@types/node@26.2.0': dependencies: undici-types: 8.3.0 @@ -18836,6 +18887,8 @@ snapshots: base64id@2.0.0: optional: true + base64url@3.0.1: {} + baseline-browser-mapping@2.10.38: {} bcrypt-pbkdf@1.0.2: @@ -21572,7 +21625,7 @@ snapshots: isomorphic-fetch@2.2.1(encoding@0.1.13): dependencies: - node-fetch: 2.7.0(encoding@0.1.13) + node-fetch: 2.7.0(@noble/hashes@2.3.0)(encoding@0.1.13) whatwg-fetch: 3.6.20 transitivePeerDependencies: - '@noble/hashes' @@ -21711,6 +21764,8 @@ snapshots: ms: 2.1.3 semver: 7.8.5 + jsrsasign@11.1.5: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -22660,7 +22715,7 @@ snapshots: node-fetch-native@1.6.7: {} - node-fetch@2.7.0(encoding@0.1.13): + node-fetch@2.7.0(@noble/hashes@2.3.0)(encoding@0.1.13): dependencies: whatwg-url: 17.1.0(@noble/hashes@2.3.0) optionalDependencies: @@ -24667,6 +24722,8 @@ snapshots: undici-types@5.26.5: {} + undici-types@7.24.6: {} + undici-types@8.3.0: {} undici@8.10.0: {}