Skip to content

refactor(specs): nest client and commerce specifications under specs/openiap - #421

Merged
hyochan merged 9 commits into
mainfrom
refactor/graphql-spec-ssot
Sep 3, 2026
Merged

hyochan merged 9 commits into
mainfrom
refactor/graphql-spec-ssot

Conversation

@hyochan

@hyochan hyochan commented Sep 2, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Move the client GraphQL contract from packages/gql to specs/openiap/client and rename its package to @hyodotdev/openiap (root manifest becomes openiap-monorepo).
  • Move the Commerce Protocol from specs/openiap-kit to specs/openiap/commerce-protocol; specs/openiap/* now holds exactly the two publishable specifications while packages/kit stays the deployable implementation.
  • Update every workspace consumer, CI path filter, release lane, Docker build, audit script, and human/AI document for the new layout, keeping historical tags (openiap-commerce-protocol-0.1.0) recoverable.

Changes

Specifications (specs/openiap/*)

  • specs/openiap/client: pure rename of packages/gql (137 R100 renames; generated outputs byte-identical). The package becomes publishable as @hyodotdev/openiap: publishConfig, files, compiled dist/ via prepack, published types pointing at the compiled declarations (plus typesVersions for legacy resolvers), and an openiap-source export condition so workspace consumers keep resolving the authored sources. A consumer smoke test packs the tarball and compiles it under strict TypeScript flags (exactOptionalPropertyTypes, erasableSyntaxOnly).
  • specs/openiap/commerce-protocol: pure rename of specs/openiap-kit; repository.directory updated. The README stays free of funding content, as its decentralization test requires.
  • specs/openiap/README.md documents the boundary: specifications may publish packages but are never deployed services. The root README lists both contracts in a dedicated Specifications section instead of under Packages.

Workspace and consumers

  • Root workspaces = packages/* + specs/openiap/*; bun.lock workspace names follow.
  • packages/apple, packages/google, packages/mcp-server, packages/conformance, packages/kit tests, and framework docs point at the new paths and package name; packages/mcp-server and packages/kit tsconfigs opt into the openiap-source condition.
  • kit-api.ts mergeHeaders returns NonNullable<RequestInit["headers"]> so the published tarball compiles for consumers with exactOptionalPropertyTypes (synced to react-native-iap and expo-iap through the manifest).
  • Dependency advisories published on 2026-09-02 made the unconditional dependency audits red on every branch; this PR resolves them without touching manifests beyond the root overrides block: qs 6.16.0 and fast-uri 3.1.7 overrides in the root Bun lock, transitive bumps (@humanfs/node, qs, @xmldom/xmldom, fast-uri) in the react-native-iap Yarn lock, and time-boxed OSV exceptions for @xmldom/xmldom in the Expo lockfiles (build-time tooling only).

CI, Docker, release

  • ci.yml, framework CI workflows, deploy-kit.yml, and the Apple/Google/MAUI release lanes use the new paths; deploy-kit.yml PR paths include the client kit-api contract and the Docker build stays rooted at the monorepo. dependabot-bun-lockfile.yml covers specs/**/package.json.
  • packages/kit/Dockerfile copies both nested specification packages.
  • release-commerce-protocol.yml, assert-release-tag.mjs, release-branch-policy.mjs, and generate-sbom.mjs keep a historical manifest fallback (the canonical + historical manifest pair is declared once in release-branch-policy.mjs and reused by the other two scripts) so current retries, provenance checks, and SBOM recovery still work for tags that predate the move; expected misses no longer print fatal: noise, and a tag with no manifest reports every probe.
  • audit-repo-layout.mjs rejects legacy roots, an umbrella specs/openiap/package.json, and service deployment manifests (Dockerfile, compose, fly*.toml, vercel.json, convex.json) under specs/; its test stays dependency-free for the Node-only CI job.
  • audit-ci-path-filters.mjs guards the kit consumer filters and the monorepo Docker context.

Tooling

  • scripts/sync-sponsors.mjs also audits specs/openiap/*/README.md (specifications receive the block only after opting in with the markers; markerless ones are still checked for unmanaged sponsor sections and hardcoded funding links) and renders a Prettier-stable block so a formatter pass can no longer drift managed READMEs; all managed READMEs were regenerated.
  • audit-schema-semver.mjs compares against pre-move refs (fallback to the prior canonical directory) and drops a dead export.
  • libraries/expo-iap/example/scripts/build-vega-example.mjs: the Vega build now copies the example src/ through the same library-import rewrite as app/, the rewrite accepts any nesting depth, and the example's error-mapping helper (not part of the public entry) resolves through a local alias of the copied library module. main's Expo Vega build has failed since feat: add OpenIAP Commerce Protocol #419 because src/utils/errorUtils.ts imports ../../../src/utils/errorMapping; the device regression for this PR surfaced it.

Documentation and AI context

  • AGENTS.md, CONTRIBUTING.md, SECURITY.md, README.md, knowledge/internal/*, Claude/Codex/Gemini adapters, library AGENTS.md files, kit docs (deploy triggers and pre-commit gate scope now match the workflow and hook), and the docs site describe the nested specification layout; the compiled agent context and llms*.txt are regenerated from those sources. The specifications are described by package name (nothing is on npm yet), the kit pre-commit docs name exactly the verify steps the hook mirrors, and the last gql package mentions are gone.

Test plan

  • bun install --frozen-lockfile, git diff --check
  • Client spec: bun run generate is a no-op, 182 vitest tests (incl. packed-tarball consumer under strict flags), schema semver audit clean vs origin/main
  • Commerce Protocol: 336 tests incl. generated-artifact --check and Prettier
  • Conformance 40, MCP server 65 + lint/build, agent scripts 72, IAPKit 1857 (1 skipped) + tsc, expo and react-native typechecks, yarn install --immutable
  • Audits: layout (also without node_modules), CI path filters, agent surfaces, kit contract, release sync, facts, SBOM, release policy, deprecations, sponsors, parity, docs, dependency vulnerabilities (bun audit + OSV on all 8 locks)
  • Old-tag recovery (openiap-commerce-protocol-0.1.0) verified for release retry, provenance, and SBOM scripts in a scratch clone
  • Device regression (e2e-tests) for the implicated rows, including RN/Expo Vega on a Fire TV Stick and the Expo Onside iOS build, is recorded in a PR comment; iOS device rows are blocked on a USB connection

Preview recording: not applicable — this change has no visual or interactive surface; the proof is the test and audit matrix above.

🤖 Generated with Claude Code

@hyochan hyochan added cross-platform Cross-platform (both Android & iOS) expo-iap expo-iap library react-native-iap react-native-iap library ፦ refactor ⬡ protocol 💨 ci Cloud integration 📖 documentation Improvements or additions to documentation 📱 iOS Related to iOS 🤖 android Related to android labels Sep 2, 2026
@codecov

codecov Bot commented Sep 2, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 76.00%. Comparing base (749dbdb) to head (ba19bfa).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main     #421   +/-   ##
=======================================
  Coverage   76.00%   76.00%           
=======================================
  Files         155      155           
  Lines       16561    16561           
  Branches     4763     4763           
=======================================
  Hits        12588    12588           
  Misses       3973     3973           
Flag Coverage Δ
expo-iap 90.29% <ø> (ø)
flutter-inapp-purchase 90.42% <ø> (ø)
iapkit 67.16% <ø> (ø)
react-native-iap 93.11% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
React Native IAP 93.11% <ø> (ø)
Expo IAP 90.29% <ø> (ø)
flutter_inapp_purchase 90.42% <ø> (ø)
IAPKit Server 92.12% <ø> (ø)
IAPKit Convex 61.02% <ø> (ø)
Files with missing lines Coverage Δ
libraries/expo-iap/src/kit-api.ts 100.00% <ø> (ø)
libraries/react-native-iap/src/index.ts 94.15% <ø> (ø)
libraries/react-native-iap/src/kit-api.ts 100.00% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread specs/client/scripts/standalone-generated-refreshers.test.mjs Fixed
Comment thread specs/client/scripts/standalone-generated-refreshers.test.mjs Fixed
Comment thread specs/client/scripts/standalone-generated-refreshers.test.mjs Fixed
@hyochan

hyochan commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Device regression (e2e-tests) for this PR

Scope: the diff moves the client spec and Commerce Protocol, changes kit-api.ts (return type only, synced to react-native-iap and expo-iap), and touches packages/kit docs/tests. Rows were run for the implicated libraries (react-native-iap, expo-iap, maui-iap) plus the native packages and the local IAPKit receipt vertical. Flutter, KMP, and Godot are not implicated: the PR changes only README.md and AGENTS.md under those libraries.

Devices: Pixel 2 HT79F1A00473 (Google Play, license tester), Fire HD GN43T503515200BA (Amazon App Tester), Fire TV Stick G0733M085512021G (Vega OS 1.2, developer mode), iPhone 13 mini 35C8EE6D-88E7-592F-96EA-E02B8ACB1756 (wireless only, see BLOCKED rows). Local IAPKit server: compiled binary on 127.0.0.1:3101 via adb reverse (port 3100 was held by an unrelated process), Martie production Convex deployment.

Platform/package                | Target/device        | Command/flow                                   | Result  | Notes
packages/kit local IAPKit Android | HT79F1A00473       | Martie purchase → verify → finish              | PASS    | verify_request 200 isValid:true store=google, corrId 580265bf-… (ENTITLED) and f1efbf33-… (PENDING_ACKNOWLEDGMENT); finishTransaction succeeded; one canonical purchase row in the Martie Convex dashboard
packages/kit local IAPKit iOS   | 35C8EE6D-…           | —                                              | BLOCKED | iPhone is paired wirelessly only; `xcrun devicectl` reports `connecting` and times out (needs USB)
packages/google Play/Fire/Horz  | local Gradle         | compile Play/Horizon/Amazon flavors + :openiap:test | PASS | BUILD SUCCESSFUL
packages/apple iOS              | local SwiftPM        | swift build + swift test                       | PASS    | all suites passed
RN Android                      | HT79F1A00473         | build/install/products/purchase/finish         | PASS    | `dev.hyo.martie`, 3 products, consumable purchase completed, available purchases lists the GPA transaction
RN FireOS                       | GN43T503515200BA     | build/install/products/purchase                | PASS    | App Tester purchase completed; the optional local-IAPKit verify of the Amazon receipt returned 400 "Amazon RVS shared secret is not set for this project" (project configuration; the matrix marks IAPKit/FireOS as n/a)
RN Horizon                      | local Gradle         | assembleHorizonDebug                           | PASS    | build-only
RN iOS                          | 35C8EE6D-…           | —                                              | BLOCKED | same USB prerequisite
RN Vega                         | G0733M085512021G     | build:vega:debug + build:vega:release + run:vega:firetv | PASS | `dev.hyo.openiap.rniap.example.main is running`; purchase UI not exercised (VegaOS exposes no screencap/input on this device)
Expo Android                    | HT79F1A00473         | build/install/products/purchase                | PASS    | consumable purchase completed
Expo FireOS                     | GN43T503515200BA     | prebuild(fireos)/assemble/install/products/purchase | PASS | 2 products, "Purchase completed successfully"
Expo Horizon                    | local Gradle         | prebuild(horizon)/assembleDebug                | PASS    | build-only; Play prebuild restored afterwards
Expo iOS                        | 35C8EE6D-…           | —                                              | BLOCKED | same USB prerequisite
Expo Onside                     | generic iOS          | EXPO_IAP_ONSIDE=1 prebuild → pod install → xcodebuild | PASS    | build-only; `EXPO_IAP_ONSIDE=1` prebuild, `pod install` (OnsideKit in Podfile.lock, `onside` + `dev.hyo.martie.onside-auth` in Info.plist), `xcodebuild` generic/platform=iOS BUILD SUCCEEDED, `OnsideKit.framework` embedded
Expo Vega                       | G0733M085512021G     | build:vega:debug + build:vega:release + run:vega:firetv | PASS    | `dev.hyo.openiap.expo.example.main is running`; the first attempt failed on `main`'s pre-existing bug (the example `src/` copy skipped the library import rewrite, so `src/utils/errorUtils.ts` could not resolve `../../../src/utils/errorMapping`); fixed in 245fac9e (import rewrite for nested paths) and 45e5cbd7 (local alias for the error-mapping helper the Kepler entry does not export), then rebuilt; the Debug and Release bundles now contain the helper; purchase UI not exercised (no screencap/input on VegaOS)
MAUI Android                    | HT79F1A00473         | build/run/products/purchase                    | PASS    | consumable purchase completed
MAUI FireOS / Horizon / iOS     | —                    | —                                              | NOT RUN | maui-iap changes are documentation and a launch.json path only
Flutter / KMP / Godot rows      | —                    | —                                              | NOT RUN | not implicated (README/AGENTS.md only)

Blocked rows need the iPhone attached over USB (or an explicit waiver naming the rows) before the device gate can be considered cleared.

@hyochan

hyochan commented Sep 2, 2026 •

Copy link
Copy Markdown
Member Author

CodeQL: the five js/file-system-race alerts are main's, relocated

The CodeQL check fails on "5 high" new alerts, all js/file-system-race in specs/openiap/client/scripts/standalone-generated-refreshers.test.mjs. main carries the same five findings in the same test at its pre-move path, open since 2026-08-15:

this PR line main line
#406 318 #299 273
#407 320 #300 275
#408 328 #301 282
#409 333 #302 285
#410 341 #303 292

Each pair covers the same statements: the read of the refreshed file, then the later path-based reads and writes for the idempotency, invalid-body, and download-failure cases. They are reported as new only because the file moved and was reformatted for the destination directory's Prettier config, which breaks alert fingerprint matching. refs/pull/421/merge lists exactly these five and nothing else, so the PR introduces no new rule and no additional finding.

They are false positives: every path is a mktemp directory created for that one case, and the test is a single sequential process, so nothing can replace the file between the check and the use.

245fac9e moved the success-case read behind one descriptor. That shape is still right — content and mode then describe one inode across the refresher's atomic rename — but it does not change the CodeQL outcome, so e76e2742 corrects the comment that claimed it would.

Unrelated to the alerts, the check summary also warns that /language:swift/component:flutter and /language:swift/component:godot were not run. That is the scope detector working as intended: this PR changes only README.md and AGENTS.md under those two libraries.

hyochan and others added 5 commits September 3, 2026 17:28
…openiap

Move the client GraphQL contract from packages/gql to specs/openiap/client
(published as @hyodotdev/openiap) and the Commerce Protocol from
specs/openiap-kit to specs/openiap/commerce-protocol, so specs/openiap holds
exactly the two publishable, never-deployed specifications while packages/kit
stays the IAPKit implementation.

- Root workspaces become packages/* and specs/openiap/*; the root manifest is
  renamed to openiap-monorepo and every workspace consumer, CI path filter,
  release lane, Docker build, audit script, and human/AI document follows the
  new layout.
- The client package ships compiled dist declarations for npm consumers and an
  openiap-source export condition for workspace TypeScript; mergeHeaders in
  kit-api.ts returns NonNullable<RequestInit["headers"]> so strict consumer
  flags compile the tarball.
- Release, provenance, and SBOM scripts keep a historical manifest fallback so
  openiap-commerce-protocol-0.1.0 stays recoverable without stderr noise.
- audit-repo-layout rejects legacy roots and service deployment manifests
  under specs/; its test no longer needs npm dependencies for the Node-only
  CI job. audit-schema-semver compares against pre-move refs.
- sync-sponsors audits specs/openiap/*/README.md (specifications opt in with
  the block markers) and renders a Prettier-stable block; managed READMEs
  are regenerated.
- Dependency advisories that turned the unconditional audits red are resolved
  through root overrides (qs, fast-uri), react-native-iap transitive bumps,
  and time-boxed Expo OSV exceptions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- expo-iap: the Vega example build copies `src/` through the same library
  import rewrite as `app/`, and the rewrite accepts any nesting depth; the
  Expo Vega build has failed on main since #419 because
  `src/utils/errorUtils.ts` imports `../../../src/utils/errorMapping`
- release tooling: declare the Commerce Protocol manifest pair (canonical and
  historical path) once in `release-branch-policy.mjs` and reuse it from
  `assert-release-tag.mjs` and `generate-sbom.mjs`; a missing manifest now
  reports the canonical path
- client spec: give the packed-tarball consumer test an explicit hook budget
  and quieter npm install; read the refreshed file through one descriptor so
  the refresher test no longer stats and then reads the same path
- workspace runtimes: kit `dev:server`/`build:server` and mcp-server
  `start`/`start:http` pass `--conditions=openiap-source` so bun resolves the
  authored client-spec sources like the tsconfigs do, guarded by the parity
  audit; the client README logo uses the hosted asset so the npm README renders
- sponsors: test the Prettier-stable shape of the generated block, and make
  the parity audit require the sponsor markers in the client spec README
- docs: describe the specifications by package name rather than as published,
  rename the directory-responsibility headings, drop the last `gql` package
  mentions, and state exactly which `verify` steps the kit pre-commit gate
  mirrors; compiled agent context regenerated

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Vega copy of the example rewrote `src/utils/errorMapping` imports to
`expo-iap`, but the Kepler entry does not export `getUserFriendlyErrorMessage`,
so Release builds would have thrown on the first error display. Route that
import to a local alias package that re-exports the copied library module, and
correct the parity-audit comment about unresolved value imports.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The comment claimed the single-descriptor read avoids a CodeQL
check-then-use finding. It does not: js/file-system-race still reports the
same five alerts main carries for this test, because the later path-based
accesses pair with the openSync instead of the previous statSync. State the
real reason — one descriptor keeps the content and mode assertions on one
inode across the refresher's atomic rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ired

Rebasing onto main's Android IAP opt-out merge refreshed
libraries/expo-iap/example/bun.lock, bumping @xmldom/xmldom 0.8.14 to 0.8.15 and
plist's copy 0.9.11 to 0.9.12. GHSA-6gmq-8vp8-gcm6 no longer matches, so
audit:dependencies failed the branch on the unused exception. Removing it makes
the file identical to main again; libraries/expo-iap/osv-scanner.toml went away
with main's own deletion during the same rebase.

Also repad the review-pr.md check table, whose first column this branch widened
without realigning the other rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hyochan
hyochan force-pushed the refactor/graphql-spec-ssot branch from e76e274 to 16fa73f Compare September 3, 2026 08:33
@hyochan

hyochan commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Rebased onto main (4715d8ac) — head is now 16fa73fc

main picked up the Android IAP opt-out train (8ece20cd, 749dbdb1, 4715d8ac) while this PR was open. Two conflicts, both resolved by keeping each side's intent:

  • bun.lock / package.json — main added a root dependencies block for fast-uri and qs alongside the overrides; this branch renamed the root manifest to openiap-monorepo. Kept both. bun install afterwards reports no lockfile change.
  • libraries/expo-iap/osv-scanner.toml — main deleted the file because its lockfile refresh retired the image-size advisories; this branch had modified it. Took the deletion.

That surfaced one real fallout, caught by audit:dependencies rather than assumed: main's refresh bumped @xmldom/xmldom 0.8.14 → 0.8.15 (and plist's copy 0.9.11 → 0.9.12) in libraries/expo-iap/example/bun.lock, so this branch's GHSA-6gmq-8vp8-gcm6 exception became unused and failed the audit. Removed in 16fa73fc; that file is now identical to main.

Verification rerun after the rebase

  • 14 audits clean: layout, ci-paths, parity, release-state, dependencies, docs, agents, sponsors, kit-contract, deprecations, schema-semver, facts, release-sync, research
  • bun run generate — no drift
  • client spec 22 files / 182 tests; kit 110 files / 1857 tests, 1 skipped; kit lint (tsc + convex typecheck + eslint) clean; agent scripts 72 tests; docs production build

Runtime delta, for the device-regression decision

Diffed against 4715d8ac, the shipped SDK code in this PR contains no runtime change on any platform:

File Change
specs/openiap/client/src/generated/Types.swift byte-identical rename from packages/gql/src/generated/Types.swift
packages/apple/Sources/Models/OpenIapError.swift one doc-comment path
libraries/react-native-iap/src/kit-api.ts RequestInit["headers"] → NonNullable<RequestInit["headers"]> (type-only, erased at emit)
libraries/expo-iap/src/kit-api.ts same type-only change
libraries/react-native-iap/src/index.ts one comment

The only behavioural change anywhere is libraries/expo-iap/example/scripts/build-vega-example.mjs, and both Vega rows already passed on hardware.

This is evidence for the gate decision, not a waiver: the three BLOCKED iOS rows still need either a run or an explicit written waiver from the repository owner before merge.

hyochan and others added 3 commits September 4, 2026 02:25
…rface

CodeQL reported five js/file-system-race alerts against the standalone
refresher test, the same five main carries, because the run interleaved a stat
of one refreshed target with the reads and writes the later cases perform on
it. Collect the replaced paths and assert their modes in a second pass, so no
check sits between those uses. The assertion keeps its teeth: reverting a
refresher's chmod 0644 to 0600 fails the test with expected 420, actual 384.

The ecosystem diagram described openiap-google as Play Billing only and named
neither Amazon target, and the independent server contract stood alone with no
implementation next to it.

- openiap-google reads "Play, Amazon, Horizon" and its Android mark is labelled
  for Fire OS, which is an Android target built from the same package through
  the amazon product flavor
- expo-iap and react-native-iap list Vega OS ahead of the Onside module, both
  pointing at the existing Amazon store guide; Onside stays Expo-only
- the contract band lists implementations under the role each one declares, so
  a role renders its own group, starting with IAPKit as a conforming provider

The Swift Package badge had a hard-coded 2.* tag filter and rendered "no
matching tags found" once the package reached 3.x; the filter now derives its
major from openiap-versions.json.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…layers

specs/openiap/ repeated the repository's own name one level down, and every
sibling root — packages/, libraries/, plugins/, knowledge/ — is flat. The two
specifications now sit directly under specs/, which is also the vocabulary the
repository already uses for them.

- specs/openiap/client -> specs/client, specs/openiap/commerce-protocol ->
  specs/commerce-protocol, and the umbrella README becomes specs/README.md
- audit-repo-layout enforces the flat layout from a SPECIFICATION_ROOTS list,
  rejects the old umbrella, and names an unknown root instead of demanding it
  move under openiap; sync-sponsors scans specs/ for the same reason
- commerce-protocol schema layers renumber 10..70 to 01..07, keeping assembly
  order; the rebuilt artifacts are byte-identical

Flattening shortened every path inside specs/ by one segment, which broke three
things that had encoded the old depth:

- nine scripts resolved the repository root with ../../../..
- audit-schema-semver could no longer find a base ref, so it failed before
  comparing anything
- the agent indexer matched specs/openiap literally when naming a file's package

Also commit the Prettier formatting that the previous commit left in the
working tree, which is what Test Docs was failing on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Commerce Protocol release test pinned the bump step's working directory to
specs/openiap/commerce-protocol, so flattening the specifications failed the
release-state audit in CI. The legacy specs/openiap-kit SPEC_PATH assertion
stays: that fallback still recovers tags cut before the move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hyochan

hyochan commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Device regression: the three iOS rows now pass

Re-run against f578b68c, which carries the full specs/ flattening, so these results cover the final layout rather than the intermediate one.

The rows were previously BLOCKED because xcrun devicectl sat at tunnelState: connecting. That was a wedged CoreDevice pairing daemon, not a missing cable: transportType was already wired and pairingState already paired. Restarting CoreDeviceService and remotepairingd — both run as the user, no sudo — brought the tunnel up.

Platform/package       | Target/device            | Command/flow                     | Result | Notes
Local (IAPKit) iOS     | 00008110-0004081E1A79801E | Martie purchase → verify → finish | PASS   | corrId 78b4fcfd-08f5-4161-9cd9-ebc45d863c39, POST /v1/purchase/verify 200, isValid:true, state READY_TO_CONSUME, JWS 5335 bytes, specVersion 3.4.0
RN iOS                 | 00008110-0004081E1A79801E | build/install/products/purchase   | PASS   | consumable "10 Bulbs"; app shows Local (IAPKit) Verification valid:true state:ready-to-consume store:apple
Expo iOS               | 00008110-0004081E1A79801E | build/install/products/purchase   | PASS   | non-consumable "Certified Badge"; corrId c6098df5-2d3b-49a7-8b52-11bc39c9666f, 200, isValid:true, state ENTITLED

The two product types resolve to different states through the same binding — READY_TO_CONSUME for the consumable, ENTITLED for the non-consumable — and the Expo catalog drops from 3 to 2 products after the non-consumable purchase. Both stores route as store: apple and both carry specVersion: 3.4.0, so the client contract still identifies itself correctly from its new path.

The local IAPKit server ran the compiled binary against the real Martie Convex deployment on 192.168.0.4:3101 (port 3100 was held by an unrelated process). Purchases were Apple sandbox, marked "For testing purposes only. You will not be charged."

One finding worth recording: the first verification attempt failed with INVALID_API_KEY. The key in the React Native example's ignored .env is no longer valid for that deployment; the key the Android rows use is. Short payloads hid this because input validation rejects them before the key is checked — reproducing it needed a JWS over the 100-character minimum.

An external review of the previous restructuring pointed out that deferring the
mode assertion to the end of the run let a later successful replace mask a wrong
mode on the first one: the idempotent run rewrites the same path at 0644, so a
refresher that got the first replace wrong still passed.

Split the case in two. The first test seeds 0600, runs each refresher once, and
records that replacement's own mode for the post-loop assertion, so 0644 now
proves the refresher set it rather than inheriting it. The idempotent and
preserve-on-failure cases move to their own test, which asserts no modes at all.
The check still sits outside the loop, so the js/file-system-race pattern stays
gone.

Mutation proof for the masking case: making the refresher chmod 0600 on the
first replace and 0644 afterwards fails the new test with expected 420, actual
384. Under the previous structure that mutation passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hyochan

hyochan commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Device regression, complete matrix

Re-run against ba19bfa3. Every row below was exercised on the flattened layout, not on an earlier commit.

Platform/package        | Target/device            | Command/flow                      | Result | Notes
packages/apple iOS      | local SwiftPM            | swift build + swift test          | PASS   | 164 tests, 0 failures
packages/google Play    | local Gradle             | compile Play/Horizon/Amazon + test | PASS  | all three store flavors
Local (IAPKit) iOS      | 00008110-0004081E1A79801E | Martie purchase → verify → finish | PASS   | corrId 78b4fcfd, 200, isValid:true, READY_TO_CONSUME, JWS 5335 bytes
Local (IAPKit) Android  | HT79F1A00473             | Martie purchase → verify → finish | PASS   | corrIds 3805b760 / ec922385 / c6dde18a, 200, isValid:true, purchaseToken 123 bytes
RN iOS                  | 00008110-0004081E1A79801E | build/install/products/purchase  | PASS   | consumable "10 Bulbs"
RN Android              | HT79F1A00473             | build/install/products/purchase   | PASS   | Play sheet showed "Test card, always approves" and "This is a test order"
Expo iOS                | 00008110-0004081E1A79801E | build/install/products/purchase  | PASS   | non-consumable "Certified Badge", corrId c6098df5, ENTITLED, catalog 3 → 2
Expo Android            | HT79F1A00473             | build/install/products/purchase   | PASS   | Local (IAPKit) verification selected
Flutter / KMP / Godot   | —                        | —                                 | NOT RUN | no non-documentation change: `git diff --name-only origin/main HEAD -- libraries/<lib>` is README.md and AGENTS.md only
MAUI                    | —                        | —                                 | NOT RUN | one comment line in OpenIap.cs (`packages/gql` → `specs/client`) and a launch.json path
Vega (RN and Expo)      | G0733M085512021G         | build debug/release/run           | PASS (earlier) | build-vega-example.mjs is unchanged since 45e5cbd7, the commit those rows were run against

The generated type files for all six libraries are byte-identical to main; only their source path moved. That, plus the per-library diff above, is why the untested rows are untested.

Lifecycle states resolved per store and product type through the same binding:

apple  / consumable      → READY_TO_CONSUME
apple  / non-consumable  → ENTITLED
google / consumable      → PENDING_ACKNOWLEDGMENT → ENTITLED

Every verification carried specVersion: 3.4.0, so the client contract still identifies itself correctly from specs/client.

@hyodotdev hyodotdev deleted a comment from coderabbitai Bot Sep 3, 2026
@hyodotdev hyodotdev deleted a comment from coderabbitai Bot Sep 3, 2026
@hyochan
hyochan merged commit 378549f into main Sep 3, 2026
68 checks passed
@hyochan
hyochan deleted the refactor/graphql-spec-ssot branch September 3, 2026 19:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🤖 android Related to android 💨 ci Cloud integration cross-platform Cross-platform (both Android & iOS) 📖 documentation Improvements or additions to documentation expo-iap expo-iap library 📱 iOS Related to iOS ⬡ protocol react-native-iap react-native-iap library ፦ refactor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants