Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
509 changes: 497 additions & 12 deletions package-lock.json

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@
"description": "JavaScript SDK for Base44 API",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./iap": {
"types": "./dist/iap/index.d.ts",
"default": "./dist/iap/index.js"
},
"./dist/*.js": {
"types": "./dist/*.d.ts",
"default": "./dist/*.js"
},
"./dist/*.d.ts": "./dist/*.d.ts",
"./dist/*": {
"types": "./dist/*.d.ts",
"default": "./dist/*.js"
},
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"iap": [
"dist/iap/index.d.ts"
]
}
},
"type": "module",
"files": [
"dist"
Expand All @@ -26,12 +53,14 @@
"create-docs:process": "node scripts/mintlify-post-processing/file-processing/file-processing.js"
},
"dependencies": {
"@apple/app-store-server-library": "^3.1.0",
"axios": "^1.18.1",
"partysocket": "^0.0.23",
"socket.io-client": "^4.8.3",
"uuid": "^13.0.2"
},
"devDependencies": {
"@peculiar/x509": "^2.0.0",
"@types/hast": "^3.0.4",
"@types/node": "^25.0.1",
"@types/unist": "^3.0.3",
Expand All @@ -43,6 +72,7 @@
"eslint": "^9.39.2",
"eslint-plugin-import": "^2.32.0",
"nock": "^13.4.0",
"reflect-metadata": "^0.2.2",
"typedoc": "^0.28.14",
"typedoc-plugin-markdown": "^4.9.0",
"typescript": "^5.3.2",
Expand Down
3 changes: 3 additions & 0 deletions scripts/mintlify-post-processing/appended-articles.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,8 @@
"type-aliases/integrations": [
"interfaces/CoreIntegrations",
"interfaces/CustomIntegrationsModule"
],
"interfaces/IapModule": [
"interfaces/IapServerApiModule"
]
}
24 changes: 24 additions & 0 deletions scripts/mintlify-post-processing/method-order.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,29 @@
"functions": [
"invoke",
"fetch"
],
"iap": [
"hasActiveSubscription",
"getSubscriptionState",
"getEntitlements",
"getPurchase",
"listTransactions",
"listRefunds",
"listPendingConsumptionRequests",
"handleNotification",
"handleSignedPayload",
"recordTransaction",
"syncEntitlements",
"onEvent",
"checkSetup",
"appAccountTokenFor",
"verifyTransaction",
"verifyRenewalInfo",
"verifyNotification"
],
"iap-server-api": [
"sendConsumptionInformation",
"requestTestNotification",
"getTestNotificationStatus"
]
}
68 changes: 67 additions & 1 deletion scripts/mintlify-post-processing/types-to-expose.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,71 @@
"CoreIntegrations",
"SortField",
"SsoModule",
"UpdateManyResult"
"UpdateManyResult",
"CreateIapClientOptions",
"DecodedNotification",
"DecodedNotificationData",
"DecodedNotificationSummary",
"DecodedRenewalInfo",
"DecodedTransaction",
"IapConfig",
"IapConfiguredProductType",
"IapModule",
"IapProductConfig",
"IapVerificationErrorCode",
"IapEnvironment",
"IapProductType",
"IapSetupReport",
"IapEntityName",
"IapEntitySchema",
"IapSchemaField",
"IapEvent",
"IapEventHandler",
"IapEventType",
"IapExpiryReason",
"IapRenewReason",
"IapStartReason",
"HandleNotificationResult",
"RecordTransactionOptions",
"RecordTransactionResult",
"SyncPayload",
"SyncResult",
"SubscriptionState",
"Entitlements",
"SubscriptionQuery",
"EntitlementQuery",
"TransactionQuery",
"IapSubscriptionStatus",
"IapExpirationReason",
"IapSubscriptionOffer",
"IapRevocation",
"OwnedNonConsumable",
"OwnedNonRenewingSubscription",
"IapTransactionRecord",
"IapSubscriptionRecord",
"IapNotificationRecord",
"IapConsumptionRequestRecord",
"IapNotificationOutcome",
"IapConsumptionOutcome",
"IapRecordSource",
"IapServerApiModule",
"IapServerApiConfig",
"ConsumptionRequestBody",
"IapDeliveryStatus",
"IapRefundPreference",
"TestNotificationResult",
"TestNotificationStatus",
"SendAttempt",
"SendAttemptResult",
"IapAppleSubscriptionStatus",
"IapConsumptionRequestReason",
"IapExpirationIntent",
"IapOfferDiscountType",
"IapOfferType",
"IapOwnershipType",
"IapRevocationType",
"IapTransactionReason",
"IapConfigErrorCode",
"IapSetupErrorCode",
"IapStoreErrorCode"
]
46 changes: 46 additions & 0 deletions src/iap/account-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Mapping a Base44 user to the UUID Apple signs into their purchases.
*
* StoreKit accepts a UUID at purchase time (`appAccountToken`) and Apple
* returns the same value in every resulting transaction — including renewals
* years later. That signed round trip is the entire attribution mechanism: it
* is the only way to know whose purchase a transaction is, without trusting
* anything the client claims.
*
* The mapping is a deterministic version-5 UUID of the user id under a fixed
* namespace, so it is a pure function. The shell, the web app and the backend
* all derive the same UUID from the same user id with no lookup table to keep
* in sync, and nothing to migrate.
*
* Two consequences worth knowing:
*
* - The namespace is part of the contract. Changing it orphans every purchase
* already attributed under the old one.
* - The mapping is one-way in practice. Recovering a user id means deriving
* the UUID for a candidate user and comparing, not inverting the hash.
*
* @internal
*/
import { v5 as uuidv5 } from "uuid";

/**
* The namespace every Base44 in-app purchase account token is derived under.
*
* A fixed, arbitrary version-4 UUID. It must never change: the native shell
* derives the same value independently, and Apple has already signed the
* results into transactions that will keep renewing.
*/
export const IAP_APP_ACCOUNT_TOKEN_NAMESPACE =
"8f2b6a1e-4c5d-4e7a-9b3f-6d1a2c8e5f04";

/**
* Derives the account token for a Base44 user id.
*
* @throws {TypeError} when the user id is empty.
*/
export function appAccountTokenFor(base44UserId: string): string {
if (typeof base44UserId !== "string" || base44UserId.length === 0) {
throw new TypeError("a Base44 user id is required to derive an account token");
}
return uuidv5(base44UserId, IAP_APP_ACCOUNT_TOKEN_NAMESPACE);
}
145 changes: 145 additions & 0 deletions src/iap/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Configuration validation.
*
* Runs once, when the client is created, and refuses to build a client it
* cannot operate correctly. Failing at deploy time is the whole point: the
* alternative is discovering a missing `appAppleId` when Apple sends the first
* refund notification.
*
* @internal
*/
import { IapConfigError } from "./errors.js";
import type { IapConfig, IapProductConfig } from "./iap.types.js";
import type { IapServerApiConfig } from "./server-api/server-api.types.js";

/** A validated configuration, with every default filled in. */
export interface ResolvedIapConfig {
readonly bundleId: string;
readonly appAppleId: number;
readonly products: Readonly<Record<string, IapProductConfig>>;
readonly testMode: boolean;
readonly allowLocalTesting: boolean;
readonly serverApi?: IapServerApiConfig;
readonly verifier: "apple" | "builtin";
}

const PRODUCT_TYPES = new Set<IapProductConfig["type"]>([
"consumable",
"nonConsumable",
"nonRenewingSubscription",
"autoRenewableSubscription",
]);

function invalid(message: string): never {
throw new IapConfigError("IAP_INVALID_CONFIG", message);
}

/**
* Validates a configuration and fills in defaults.
*
* @throws {IapConfigError} `IAP_INVALID_CONFIG` for anything malformed, or
* `IAP_ONLINE_CHECKS_UNSUPPORTED` when online certificate checks are asked for.
*/
export function resolveConfig(config: IapConfig): ResolvedIapConfig {
if (!config || typeof config !== "object") {
invalid("an in-app purchase configuration object is required");
}

if (typeof config.bundleId !== "string" || config.bundleId.trim().length === 0) {
invalid("'bundleId' is required, e.g. \"com.example.app\"");
}

// A very common mix-up: passing the bundle id where the numeric id belongs.
if (typeof config.appAppleId === "string") {
invalid(
"'appAppleId' must be a number — the numeric App Store id from App Store " +
"Connect under App Information, not the bundle id"
);
}
if (
typeof config.appAppleId !== "number" ||
!Number.isInteger(config.appAppleId) ||
config.appAppleId <= 0
) {
invalid("'appAppleId' must be a positive integer, e.g. 1234567890");
}

if (!config.products || typeof config.products !== "object") {
invalid("'products' is required, keyed by product identifier");
}

for (const [productId, product] of Object.entries(config.products)) {
if (!product || typeof product !== "object") {
invalid(`product ${JSON.stringify(productId)} must be an object`);
}
if (!PRODUCT_TYPES.has(product.type)) {
invalid(
`product ${JSON.stringify(productId)} has type ${JSON.stringify(
product.type
)}; expected one of ${[...PRODUCT_TYPES].join(", ")}`
);
}
// Apple never expires a non-renewing subscription, so this number is the
// only thing that decides when access ends. Missing it would silently
// grant the product forever.
if (product.type === "nonRenewingSubscription") {
const days = product.nonRenewingDurationDays;
if (typeof days !== "number" || !Number.isFinite(days) || days <= 0) {
invalid(
`product ${JSON.stringify(productId)} is a nonRenewingSubscription, so it ` +
"needs 'nonRenewingDurationDays' — Apple does not expire these, so the " +
"app decides how long they last"
);
}
}
}

// `null` is treated as absent, not as invalid: a secret that was never set
// arrives that way, and the right answer then is "the API is not configured"
// — which the call itself reports clearly — rather than refusing to start.
if (
config.verifier !== undefined &&
config.verifier !== "apple" &&
config.verifier !== "builtin"
) {
invalid(`'verifier' must be "apple" or "builtin"; got ${JSON.stringify(config.verifier)}`);
}

if (config.serverApi !== undefined && config.serverApi !== null) {
const api = config.serverApi;
if (typeof api !== "object") {
invalid("'serverApi' must be an object, or left out entirely");
}
for (const field of ["keyId", "issuerId", "privateKeyP8"] as const) {
if (typeof api[field] !== "string" || api[field].trim().length === 0) {
invalid(`'serverApi.${field}' is required when serverApi is supplied`);
}
}
if (!api.privateKeyP8.includes("PRIVATE KEY")) {
invalid(
"'serverApi.privateKeyP8' does not look like a .p8 file. Pass its whole " +
"contents, including the BEGIN and END lines."
);
}
}

if (config.onlineChecks === true) {
throw new IapConfigError(
"IAP_ONLINE_CHECKS_UNSUPPORTED",
"'onlineChecks' asks for certificate revocation lookups, which this version " +
"does not implement. Leave it unset. Certificate validity is evaluated at " +
"each payload's own signedDate, which is what Apple's own library does with " +
"online checks off."
);
}

return {
bundleId: config.bundleId,
appAppleId: config.appAppleId,
products: { ...config.products },
testMode: config.testMode === true,
allowLocalTesting: config.allowLocalTesting === true,
serverApi: config.serverApi ?? undefined,
verifier: config.verifier ?? "apple",
};
}
Loading
Loading