Skip to content

feat: add mobile onboarding and StoreKit billing - #255

Open
Innei wants to merge 3 commits into
mainfrom
codex/mobile-onboarding-storekit
Open

feat: add mobile onboarding and StoreKit billing#255
Innei wants to merge 3 commits into
mainfrom
codex/mobile-onboarding-storekit

Conversation

@Innei

@Innei Innei commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add a Mobile onboarding state machine that resolves or creates the active workspace, requires owners to configure storage, and preserves recovery entry points in Explore and Account Settings
  • add native StoreKit 2 product loading, purchasing, restoration, transaction listening, and server-confirmed transaction finishing
  • introduce provider-neutral billing subjects, offers, subscriptions, entitlements, reconciliation, and App Store signed-data verification
  • add a secure, payment-free Web handoff for configuring BYO storage and corresponding Dashboard route
  • align account deletion and subscription cleanup with App Store ownership boundaries
  • add database migration, admin configuration, localized copy, design documentation, and a complete local verification report

Why

Mobile previously depended on users entering an existing tenant and had no supported storage configuration path. A dismissed onboarding sheet could also leave an incomplete account without a clear way to resume. Managed storage purchases must remain inside Apple's in-app purchase system, while connecting storage the user already owns needs a separate Web configuration surface without external payment messaging.

User and developer impact

  • new users create their gallery workspace directly in Mobile
  • workspace_required and owner storage_required steps are native non-dismissible form sheets
  • member/waiting/recovery states remain dismissible so users are not trapped by work they cannot perform
  • incomplete users can resume from Explore or Account Settings
  • BYO storage uses short-lived, one-time handoff capabilities and server-side connection validation
  • Creem and App Store subscriptions project into the same entitlement model

Validation

  • pnpm -C apps/mobile type-check
  • Mobile onboarding and shell behavior tests: 7/7 passed
  • pnpm -C apps/mobile bundle
  • Core onboarding, billing, account-deletion, and static-host tests: 18/18 passed
  • pnpm -C be/apps/core build
  • pnpm -C be/apps/dashboard build
  • native StoreKit policy tests passed
  • real local PostgreSQL, Redis, RustFS, Core, Dashboard handoff, and iPhone 17 Pro Simulator onboarding flow verified
  • Workspace creation, mandatory Storage transition, exceptional presentation recovery, BYO handoff, deep-link return, and cold-start readiness verified

Detailed evidence: docs/superpowers/reports/2026-08-05-mobile-onboarding-storekit-local-verification.md

External follow-up

Apple Sandbox/TestFlight purchase, renewal, refund, revocation, and App Store Server Notifications V2 validation still require App Store Connect products and credentials.

@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
afilmory-demo Ready Ready Preview Aug 5, 2026 5:49pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
afilmory-docs Skipped Skipped Aug 5, 2026 5:49pm

@safedep

safedep Bot commented Aug 5, 2026

Copy link
Copy Markdown

SafeDep Report Summary

Green Malicious Packages Badge Green Vulnerable Packages Badge Green Risky License Badge

Package Details
Package Malware Vulnerability Risky License Report
icon @apple/app-store-server-library @ 3.1.0
pnpm-lock.yaml be/apps/core/package.json
ok icon
ok icon
ok icon
🔗
icon @types/jsonwebtoken @ 9.0.10
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon @types/jsrsasign @ 10.5.15
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon @types/node-fetch @ 2.6.13
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon asynckit @ 0.4.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon base64url @ 3.0.1
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon buffer-equal-constant-time @ 1.0.1
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon combined-stream @ 1.0.8
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon delayed-stream @ 1.0.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon deslop-js @ 0.9.4
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon ecdsa-sig-formatter @ 1.0.11
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon form-data @ 4.0.6
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon has-flag @ 5.0.1
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon ink-link @ 5.0.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon jsonwebtoken @ 9.0.3
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon jsrsasign @ 11.1.3
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon jwa @ 2.0.1
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon jws @ 4.0.1
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lightningcss @ 1.33.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lightningcss-android-arm64 @ 1.33.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lightningcss-darwin-arm64 @ 1.33.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lightningcss-darwin-x64 @ 1.33.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lightningcss-freebsd-x64 @ 1.33.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lightningcss-linux-arm-gnueabihf @ 1.33.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lightningcss-linux-arm64-gnu @ 1.33.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lightningcss-linux-arm64-musl @ 1.33.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lightningcss-linux-x64-gnu @ 1.33.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lightningcss-linux-x64-musl @ 1.33.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lightningcss-win32-arm64-msvc @ 1.33.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lightningcss-win32-x64-msvc @ 1.33.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lodash.includes @ 4.3.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lodash.isboolean @ 3.0.3
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lodash.isinteger @ 4.0.4
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lodash.isnumber @ 3.0.3
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lodash.isplainobject @ 4.0.6
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lodash.isstring @ 4.0.1
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon lodash.once @ 4.1.1
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon node-fetch @ 2.7.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon oxlint-plugin-react-doctor @ 0.9.4
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon react-doctor @ 0.9.4
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon supports-hyperlinks @ 4.5.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon terminal-link @ 5.0.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon tr46 @ 0.0.3
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon webidl-conversions @ 3.0.1
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon whatwg-url @ 5.0.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗

View complete scan results →

This report is generated by SafeDep Github App

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4d764e838e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +49 to +50
if (auth.status === 'signedIn' && !workspaceReady && !isExplore) {
return <Redirect href="/explore" />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer redirects until onboarding readiness loads

On a cold start or deep link to Photos/Map/Studio, useMobileOnboarding() is still idle/loading, so isMobileWorkspaceReady() returns false even for an already configured workspace. This branch immediately redirects signed-in users to Explore, and once readiness later becomes ready there is no navigation back to the original/default tab, so valid signed-in sessions lose their intended route.

Useful? React with 👍 / 👎.

}
const config = this.photoStorage.mapProviderToStorageConfig(resolvedProvider)
const storage = StorageFactory.createProvider(config)
await this.withTimeout(storage.getFile(`.afilmory-connection-test/${randomUUID()}`))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail BYO validation when the test read returns null

For S3-compatible/B2/GitHub-style providers, getFile() returns null for a missing object and in several failure cases rather than throwing; because the probe intentionally reads a random path and ignores the result, invalid credentials, bucket names, or permissions can still be reported as { connected: true }. That lets the handoff page save a broken storage provider and complete onboarding even though subsequent photo storage operations will fail.

Useful? React with 👍 / 👎.

Comment on lines +242 to +245
if (products.appStoreProductId?.trim()) {
const productId = products.appStoreProductId.trim()
await this.upsertProduct(offerId, 'app_store', 'sandbox', productId)
await this.upsertProduct(offerId, 'app_store', 'production', productId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove stale product mappings during catalog sync

When an App Store product id is changed or cleared in system settings, this sync only upserts the new id and never deletes or deactivates the old billing_offer_product row; listAppStoreOffers() and transaction reconciliation both read from that table, so removed products can remain visible/purchasable and still grant the old offer. The sync should also prune provider mappings that are no longer present in the current settings.

Useful? React with 👍 / 👎.

The billing module had accumulated 25 flat files mixing four unrelated
concerns, two competing sources of truth for tenant plans, and the Creem
reconciliation pipeline living inside auth.provider.ts.

Boundaries:
- Move mobile onboarding and storage handoff out of billing into a new
  platform/mobile module; neither touches billing tables. Routes unchanged.
- Extract the Creem webhook pipeline from auth.provider.ts (569 -> 372 lines)
  into billing/providers/creem; auth now only spreads the plugins the billing
  module hands it.
- Split billing into catalog/entitlement/providers/plan/usage. BillingError
  lives at the billing root so the entitlement core does not depend on the
  provider adapters.
- Rename BillingReconciliationService to CreemBillingService; it only ever
  handled Creem.

Single source of truth:
- Drop the legacy creemSubscriptions read path from StoragePlanService;
  tenants.storagePlanId is written solely by the entitlement projection, so a
  resolved plan already implies a live grant.
- Delete the projection-bypassing writers that no longer had callers:
  BillingPlanService.updateTenantPlan, StoragePlanService.updateTenantPlan /
  updateCurrentTenantPlan / assignPlanToTenant, and the tenant repository's
  updatePlan / updateStoragePlan.

Deduplication:
- Unify the twice-written provider-event idempotency into
  BillingProviderEventService.
- Extract sha256Hex into @afilmory/be-utils (was inlined in three services),
  centralise the storage setting keys, move isByoStorageActive to the storage
  domain, and replace the repeated auth/activeTenantId controller boilerplate
  with requireSessionIdentity / requireActiveTenantIdentity.

Read paths:
- synchronizeConfiguredProducts no longer runs on every offer lookup; a
  listener drives it from module init and the system.setting.updated event.

Errors:
- Replace the bare Error throws with a typed BillingError whose code is
  persisted to billing_provider_event.error_code, so the stored code no longer
  depends on message truncation. BizException remains for caller-facing errors.

photo-asset.service.ts and data-sync.service.ts only needed an import path
update, but touching them put them through lint-staged for the first time:
they carry the resulting autofix plus a Buffer import and hoisted regex
literals for the rules autofix could not resolve.

Behaviour note: a provider-event receipt failure now throws instead of being
silently skipped on the Creem path.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c40cad09e1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

products.offer_id,
'creem'::"billing_provider",
COALESCE(NULLIF(subscriptions.creem_subscription_id, ''), 'record:' || subscriptions.id),
'legacy',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Migrate Creem subscriptions into the live environment

For tenants that already have active Creem subscriptions when this migration runs, storing the new billing row with environment = 'legacy' means later Creem webhooks are reconciled under getCreemEnvironment() (production or test) and create/update a separate billing_subscription row. If that later live row expires or is revoked, the active entitlements tied to the migrated legacy row are never inactivated, so existing customers can keep paid application/storage grants after cancellation. Migrate these rows into the environment used by reconciliation or explicitly retire the legacy row when processing the first live event.

Useful? React with 👍 / 👎.

Comment on lines +151 to +153
sourceId: `superadmin:${params.tenantId}`,
tenantId: params.tenantId,
value: dto.storagePlanId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear migrated manual storage grants when removing a plan

When a super-admin clears storage for a tenant that had storage_plan_id before migration 0020, this only targets sourceId = superadmin:<tenantId>. The migration seeded those existing manual storage grants under migration:tenant:<tenantId>, and setManualGrant(... value: null) inserts no replacement, so projection still selects the old migrated grant and the API returns success while tenants.storagePlanId remains unchanged. Clearing should also deactivate the migrated/manual grant for this tenant/kind, or use a shared source id.

Useful? React with 👍 / 👎.

…scope

P1 — account deletion tombstoned the wrong billing subjects, in both directions.
billing_subject.tenant_id cascades from tenants, so the tombstone write ran
after the tenant delete had already removed the row:

- A deleted workspace never got its tombstone, and the tombstoned branch in
  reconcileVerifiedTransaction was unreachable. Apple's later renewals resolved
  to no subject, raised APP_STORE_BILLING_SUBJECT_NOT_FOUND, returned 5xx, and
  Apple retried them indefinitely while billing_provider_event filled with
  failures.
- A transferred workspace did get tombstoned, even though it outlives its former
  owner. The successor then hit "This billing subject is no longer available" on
  every purchase, with no path to clear the flag.

Transferred workspaces now release their subject instead: the owner is cleared
and the App Store account token is rotated, which also detaches the departing
owner's Apple identity from the workspace. Deleted workspaces keep relying on
the cascade, and an unattributable notification is now accepted and dropped
rather than retried, since retrying it could never succeed.

P2 — the storage handoff capability cookie derived Secure from req.url, which is
plain http behind a TLS-terminating proxy, so the cookie carrying a capability
that can write tenant storage credentials shipped without Secure. Four other
call sites already read x-forwarded-proto; this adds isSecureRequest so there is
one place to get it right.

P2 — StoreKitBillingFinishGate was decorative: its only call site passed
isVerified: true, serverAcknowledged: true as literals, so the guard was always
satisfied, while StoreKitBillingPolicyTests asserted the false cases the caller
never produced. The real ordering lives in JS and was correct but untested.
Removed the gate and its tautological test, moved the acknowledgement flow into
storeKitAcknowledgement.ts behind an injectable port, and covered the invariant
with tests that fail if finish is ever reached without a matching server
acknowledgement.

Removing two .swift files requires a pod install before the next native build.

Note: the P1 fix depends on tenant cascade behaviour and has no automated
coverage; this repo has no DB-backed integration setup and a pure-function test
here would only restate the branch condition.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2f0f41c7b2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


for (const [storagePlanId, products] of Object.entries(storageProducts)) {
const definition = storageCatalog[storagePlanId]
if (!definition || !products) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip inactive storage plans during catalog sync

When a storage plan is marked inactive but its product mapping is left configured, this branch still calls ensureOffer(), which writes the offer back with isActive: true; listAppStoreOffers() and Creem reconciliation then treat that disabled storage plan as purchasable/grantable. The sync should skip or deactivate offers for definition.isActive === false so disabling a plan in the catalog actually removes it from billing flows.

Useful? React with 👍 / 👎.

kind: 'application_plan',
sourceId: `superadmin:${params.tenantId}`,
tenantId: params.tenantId,
value: dto.planId as BillingPlanId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the manual grant when selecting Free

When a super-admin selects the existing Free option, this stores an active manual application_plan entitlement with value free; manual entitlements outrank subscription entitlements in selectEffectiveEntitlement(), so any later App Store or Creem subscription for this tenant can still project planId as free until the row is manually removed. Treating Free as null/removing the manual grant preserves the default free fallback without blocking paid subscriptions.

Useful? React with 👍 / 👎.

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