Skip to content

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

Draft
eyalizhaki wants to merge 4 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 4 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 runs through Apple's own app-store-server-library by default, with a
    second, dependency-free WebCrypto implementation behind the same interface
    (verifier: "builtin"). Backend functions run on Cloudflare Workers with
    nodejs_compat, and workerd does implement the node:crypto X509Certificate.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).
  • 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.
  • The whole suite run twice, once per verifier implementation, to prove they agree.

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-fetch v2 and jsrsasign) is
unproven; that is what verifier: "builtin" exists for.

Known gaps

  • OCSP (onlineChecks) is not implemented; setting it true throws at construction
    rather than silently behaving as false.
  • 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.46-pr.276.f98c3b7

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.46-pr.276.f98c3b7"

Or add it to your package.json dependencies:

{
  "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>
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