feat(iap): POC Apple in-app purchase module for backend functions - #276
Draft
eyalizhaki wants to merge 4 commits into
Draft
feat(iap): POC Apple in-app purchase module for backend functions#276eyalizhaki wants to merge 4 commits into
eyalizhaki wants to merge 4 commits into
Conversation
Base44's iOS shell speaks StoreKit 2 on the device, but nothing on the server proved a purchase happened or tracked a subscription over time. Renewals, grace periods, billing retry, refunds and plan changes had no server side at all. Adds an `iap` module implementing v1 of the frozen spec: certificate and signature verification of Apple's signed tokens, the App Store Server Notifications webhook, the two device paths, entitlement reads derived from stored tokens, and three App Store Server API methods. Two decisions worth knowing. Verification is hand-rolled against native WebCrypto rather than using Apple's own library, which is Node-only and so cannot ship to a browser — this adds zero production dependencies. And the runtime sits behind a new `@base44/sdk/iap` subpath export, so browsers and React Native never download certificate code; the types are re-exported from the main entry as types only, at no runtime cost. The `exports` map keeps deep `dist/` paths resolving, which app templates rely on, verified by packing and importing from a throwaway consumer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Base44 backend functions run on Cloudflare Workers with `nodejs_compat`, not Deno — so the runtime objection to Apple's library does not apply, and workerd does implement the `node:crypto` `X509Certificate.verify()` it depends on. Adds an adapter adding Apple's `SignedDataVerifier` behind the existing verifier seam, selected with `verifier: "apple"` (now the default). The hand-rolled WebCrypto verifier stays and is one config value away, so a failure on Workers is a flip rather than a rebuild. Two things the adapter has to handle. Apple's verifier is constructed for a single environment, so accepting sandbox as well means one instance per environment; and it checks the app identifier BEFORE the environment, so a sandbox payload offered to the production instance fails as INVALID_APP_IDENTIFIER. Tokens are therefore routed by the environment they declare, which the chosen verifier then re-checks. The full suite passes against both implementations: 575 tests each, via IAP_TEST_VERIFIER. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`npm ci` failed in CI with ENOTFOUND npm.dev.wixpress.com: 44 tarball URLs in package-lock.json pointed at Wix's internal mirror rather than npmjs. They were written by a local `npm install` that picked up a user-level `registry=` setting. CI reaches npm by pinning registry.npmjs.org to the Wix embargo gateway in /etc/hosts (.github/actions/wix-gateway-proxy), so the lockfile has to name registry.npmjs.org — the internal host is not resolvable there. Rewrites the host back. Integrity hashes are unchanged and still valid, since both registries serve identical tarballs; verified by a clean `npm ci` over all 406 packages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🚀 Package Preview Available!Install this PR's preview build with npm: npm i @base44-preview/sdk@0.8.46-pr.276.f98c3b7Prefer not to change any import paths? Install using npm alias so your code still imports npm i "@base44/sdk@npm:@base44-preview/sdk@0.8.46-pr.276.f98c3b7"Or add it to your {
"dependencies": {
"@base44/sdk": "npm:@base44-preview/sdk@0.8.46-pr.276.f98c3b7"
}
}
Preview published to npm registry — try new features instantly! |
The guard read dist/index.js, which CI never builds before npm run test:unit — it passed locally only off a stale build. Now asserts the same invariant against src/, and keeps the dist check as an extra that runs only when a build is present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The mobile shell speaks StoreKit 2 on the device, so a purchase can be made there. But
nothing on the server proves a purchase happened, and nothing tracks a subscription over
time — renewals, billing grace periods, billing retry, refunds, revocations and plan
changes have no server side at all. Without that, a paid feature can only be gated on
what the client claims, which is not evidence of anything.
What this explores
An
iapmodule covering the server half end to end: certificate-chain and signatureverification of Apple's signed tokens, the App Store Server Notifications v2 webhook,
the two device paths (
recordTransaction,syncEntitlements), entitlement reads derivedfrom stored tokens, and three App Store Server API calls. 17 public methods; the one a
feature gate would use is
hasActiveSubscription(userId), which never throws.Three decisions worth a reviewer's attention, because they are the ones that would be
expensive to revisit later:
app-store-server-libraryby default, with asecond, dependency-free WebCrypto implementation behind the same interface
(
verifier: "builtin"). Backend functions run on Cloudflare Workers withnodejs_compat, and workerd does implement thenode:cryptoX509Certificate.verify()Apple's library needs — but that combination is unproven in production, so the fallback
stays one config value away. The full suite passes against both (575 tests each, via
IAP_TEST_VERIFIER).@base44/sdk/iapsubpath export, so browsers andReact Native never download certificate code. The types are re-exported from the main
entry as
export typeonly, sodist/index.jsis byte-identical and the docs pipelinestill sees the surface.
exportsmap keeps deepdist/paths resolving. App templates import@base44/sdk/dist/utils/axios-clientwithout an extension, and exports resolution doesno extension guessing — verified by packing the tarball and importing every path from a
throwaway consumer.
Storage note: Base44 entities have no upsert, unique constraint or compare-and-swap, so
newest-wins writes are built on
updateMany's server-evaluated query as a guard, andreads tolerate duplicate rows. Every workaround is isolated in
src/iap/store/behind oneinterface, so a real upsert would be a one-file change.
What would have to be settled before any of this ships
testModeis a single global boolean, so an app cannotaccept both at once. App Review testers buy with sandbox accounts against the production
build, so with it off their purchase is rejected and with it on anyone can unlock paid
features for free. This needs a design decision, not a code change.
create four of them from the exported
IAP_ENTITY_SCHEMASbefore anything can bestored. Platform-provided entities would be better than asking generated code to do it.
upsertonupdateMany. One flag would delete most ofsrc/iap/store/and theonly race that remains open.
Testing
npm run test:types,npm run test:unit,eslint srcandnpm run buildall pass. Theunit suite goes from 273 tests to 574; 301 of them are new.
Notable coverage:
real certificate bytes downloaded from apple.com, with all three embedded roots checked
byte-exact.
chain length, missing Apple marker extensions, wrong algorithm, tampered signature,
tampered payload, expired certificate, mismatched curve. The test chain is deliberately
cross-curve (P-384 root, P-256 intermediate) to mirror Apple's real shape.
delivery, and a type invented after this code was written.
expiry.
npm packplus install into a throwaway consumer, importing all four resolution paths.Nothing here has run against real Apple infrastructure — no App Store Connect, no
TestFlight build. That is the largest untested surface. In particular, whether Apple's
library survives the Workers bundler (it pulls in
node-fetchv2 andjsrsasign) isunproven; that is what
verifier: "builtin"exists for.Known gaps
onlineChecks) is not implemented; setting ittruethrows at constructionrather than silently behaving as
false.audit trail ship, but nothing points at a sink that does not exist yet.
idis undetermined, so the storedefaults to the mode that is correct either way. Confirming it would remove a round trip
per insert.
Sources
App Store Server API,
StoreKit 2, and WWDC23 session
10143 for the verification steps.
dist/import hazard is recorded in commit 4ab4f9a's own message.