Skip to content

feat(iap): POC Apple in-app purchase module for backend functions - #276

Draft
eyalizhaki wants to merge 6 commits into
mainfrom
feat/dev3-in-app-purchase-sdk
Draft

feat(iap): POC Apple in-app purchase module for backend functions#276
eyalizhaki wants to merge 6 commits into
mainfrom
feat/dev3-in-app-purchase-sdk

Conversation

@eyalizhaki

@eyalizhaki eyalizhaki commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Proof of concept — not for merge. Both halves of this feature are exploratory: the
native mobile shell that would drive it, and this server side. Opened to make the
approach reviewable and to surface the decisions it forces, not because it is ready to
ship.

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 iap module covering the server half end to end: certificate-chain and signature
verification of Apple's signed tokens, the App Store Server Notifications v2 webhook,
the two device paths (recordTransaction, syncEntitlements), entitlement reads derived
from 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:

  • Verification is Apple's own app-store-server-library. An earlier revision carried a
    second, dependency-free WebCrypto implementation as a fallback, on the theory that Apple's
    library might not survive the Cloudflare Workers bundler. It does — verified end to end on
    a production Base44 app against a real sandbox notification — so the fallback and its
    ~1 300 lines of hand-rolled DER, X.509, chain and JWS code have been removed. Apple's
    library is also stricter than what it replaced: it checks the intermediate's CA basic
    constraint, which the hand-rolled version did not.
  • 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 export type only, so dist/index.js is byte-identical and the docs pipeline
    still sees the surface.
  • The exports map keeps deep dist/ paths resolving. App templates import
    @base44/sdk/dist/utils/axios-client without an extension, and exports resolution does
    no 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, and
reads tolerate duplicate rows. Every workaround is isolated in src/iap/store/ behind one
interface, so a real upsert would be a one-file change.

What would have to be settled before any of this ships

  • Sandbox versus production. testMode is a single global boolean, so an app cannot
    accept 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.
  • Entity provisioning. An SDK cannot create a Base44 entity, so an app would have to
    create four of them from the exported IAP_ENTITY_SCHEMAS before anything can be
    stored. Platform-provided entities would be better than asking generated code to do it.
  • An upsert on updateMany. One flag would delete most of src/iap/store/ and the
    only race that remains open.

Testing

npm run test:types, npm run test:unit, eslint src and npm run build all pass. The
unit suite goes from 273 tests to 574; 301 of them are new.

Notable coverage:

  • Apple Root CA G3's own signature verified end to end through the new parser, using the
    real certificate bytes downloaded from apple.com, with all three embedded roots checked
    byte-exact.
  • Verification rejection cases against chains minted at test time — wrong root, wrong
    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.
  • All 33 notification type and subtype combinations, plus duplicate delivery, out-of-order
    delivery, and a type invented after this code was written.
  • A full sandbox subscription lifecycle: initial buy, accelerated renewal, grace period,
    expiry.
  • npm pack plus install into a throwaway consumer, importing all four resolution paths.
  • A real captured sandbox notification verified against Apple's actual trust chain — real
    Apple Root CA G3, real WWDR G6 intermediate, real receipt-signing leaf — plus its
    rejection cases (wrong bundle id, test mode off, one signature byte flipped).

Verified on a production Base44 app: a real Apple sandbox TEST notification was
delivered to a backend function running this code and handled correctly, which is what
settled the Workers question. Still untested: an actual purchase or renewal, which needs
App Store Connect products and a TestFlight build.

Known gaps

  • OCSP (onlineChecks) is off. Apple's library supports it, but enabling it also switches
    certificate validity from the payload's own signedDate to the current time, which would
    make stored tokens stop verifying over time.
  • Telemetry is deliberately omitted — the normalized event catalog and the per-notification
    audit trail ship, but nothing points at a sink that does not exist yet.
  • Whether the backend honours a caller-supplied record id is undetermined, so the store
    defaults to the mode that is correct either way. Confirming it would remove a round trip
    per insert.

Sources

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>
@eyalizhaki eyalizhaki closed this Sep 6, 2026
@eyalizhaki eyalizhaki reopened this Sep 6, 2026
eyalizhaki and others added 2 commits September 6, 2026 13:15
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>
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

🚀 Package Preview Available!


Install this PR's preview build with npm:

npm i @base44-preview/sdk@0.8.48-pr.276.4f760f9

Prefer not to change any import paths? Install using npm alias so your code still imports @base44/sdk:

npm i "@base44/sdk@npm:@base44-preview/sdk@0.8.48-pr.276.4f760f9"

Or add it to your package.json dependencies:

{
  "dependencies": {
    "@base44/sdk": "npm:@base44-preview/sdk@0.8.48-pr.276.4f760f9"
  }
}

Preview published to npm registry — try new features instantly!

eyalizhaki and others added 3 commits September 6, 2026 14:09
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>
The notification row was inserted with attempts: 1 and the commit step
then incremented it unconditionally, so every first-time notification
ended up reading 2. The increment now runs only when the row already
existed — i.e. this delivery is a retry of one that claimed the row and
then failed its entity writes.

Also adds the first test against Apple's real trust chain, using a
captured sandbox TEST notification: real Apple Root CA G3, real WWDR G6
intermediate, real receipt-signing leaf, real signature. Both verifier
implementations accept it and produce identical output, and both reject
it for the wrong bundle id, with test mode off, and with one signature
byte flipped.

That payload also corrected an assumption: Apple's chain is a P-384 root
and a P-384 intermediate signing a P-256 leaf, all ecdsa-with-SHA384. So
the cross-curve step is intermediate to leaf, not root to intermediate,
which is why the digest must come from each certificate's own
signatureAlgorithm rather than the subject key's curve.

It stays verifiable indefinitely, because certificate validity is
evaluated at the payload's own signedDate rather than the current time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Apple's app-store-server-library is verified working on a production
Base44 app: a real sandbox TEST notification was delivered to a backend
function and handled correctly. That was the only open question about
running it on Cloudflare Workers, so the fallback is no longer earning
its keep.

Removes the hand-rolled implementation — the DER reader, X.509 parser,
ECDSA helpers, chain walker and JWS parser — along with the `verifier`
config option and the two-implementation test matrix. About 1,300 lines
of security-critical code we no longer own. Apple's library is also
stricter than what it replaced: it checks the intermediate's CA basic
constraint, which the hand-rolled chain walker did not.

Two behavioural differences are now covered by tests rather than left
implicit. Apple identifies the app by bundleId on a transaction and
checks appAppleId only on a notification — the bundleId identifies the
app on its own, so nothing is lost. And a payload with no signedDate is
dated against the current time rather than refused.

Kept: the pinned Apple roots (the library takes trust anchors as a
constructor argument), the decoded payload types, the environment parse,
and the base64 codec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant