diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md index 16069374..b9976bb8 100644 --- a/.claude/commands/review-pr.md +++ b/.claude/commands/review-pr.md @@ -35,6 +35,10 @@ See [CLAUDE.md](../../CLAUDE.md) and [knowledge/internal/](../../knowledge/inter **NEVER respond with "will address in a follow-up" or "will fix later".** Fix ALL review comments NOW in the current PR. Every comment must be addressed with a code fix and committed before replying. No exceptions. +**This rule applies to architectural / refactor / "out of scope" findings too.** Phrases like "tracked as a follow-up", "is its own refactor", "belongs in a dedicated PR", "real architectural change", "future enhancement", or "tracking as follow-up rather than landing in this PR" are NOT acceptable replies — they are deferrals dressed up. If the reviewer flagged a real correctness or operational gap, implement the fix in this PR, however much code it takes. Pagination, schema additions, new validators, API rewrites, scheduler-chained mutations — all in scope. Push back ONLY when the finding is wrong on the merits (e.g. a stylistic preference contradicting an existing schema convention), and back the pushback with concrete repo evidence. + +If you are tempted to write "tracking as follow-up", stop and implement the fix instead. + For each comment: 1. **Read the code** mentioned in the comment 2. **Fix it** immediately diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b62db796..0ec81959 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.0 + bun-version: 1.3.13 - name: Install dependencies run: | @@ -129,7 +129,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.0 + bun-version: 1.3.13 - name: Install dependencies run: | @@ -173,7 +173,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.0 + bun-version: 1.3.13 - name: Install dependencies run: | @@ -209,7 +209,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.0 + bun-version: 1.3.13 - name: Install dependencies run: | @@ -249,7 +249,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.0 + bun-version: 1.3.13 - name: Install dependencies working-directory: scripts/agent diff --git a/.github/workflows/dependabot-bun-lockfile.yml b/.github/workflows/dependabot-bun-lockfile.yml index 1a52f363..12134cd6 100644 --- a/.github/workflows/dependabot-bun-lockfile.yml +++ b/.github/workflows/dependabot-bun-lockfile.yml @@ -31,7 +31,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.0 + bun-version: 1.3.13 - name: Run bun install id: install diff --git a/.github/workflows/deploy-kit.yml b/.github/workflows/deploy-kit.yml index c6bdbc2e..50de8425 100644 --- a/.github/workflows/deploy-kit.yml +++ b/.github/workflows/deploy-kit.yml @@ -33,7 +33,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.0 + bun-version: 1.3.13 - name: Install dependencies (workspace root) working-directory: ${{ github.workspace }} @@ -138,7 +138,7 @@ jobs: if: ${{ env.KIT_CONVEX_DEPLOY_KEY != '' }} uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.0 + bun-version: 1.3.13 - name: Deploy Convex functions if: ${{ env.KIT_CONVEX_DEPLOY_KEY != '' }} diff --git a/.gitignore b/.gitignore index 1a023cae..1d911303 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,27 @@ coverage/ .env.local .env.*.local +# IAPKit credential files downloaded from prod for local testing. +# Apple .p8 keys + Google service-account JSONs must never be +# committed — they'd grant attackers App Store Server API / Play +# Developer API access on the corresponding project. +*.p8 +**/service-account*.json +# Catch-all for Google Cloud project keys downloaded as +# `-.json` (the default name from the GCP console). +# Replaces the per-project `martie-c0b27-*.json` pattern that only +# helped one developer's local setup. We cover the two real formats +# the IAM console produces: +# 1. `-<5-hex suffix>-<12-hex key id>.json` +# (project IDs with the GCP-auto-generated 5-char uniqueness suffix) +# 2. `-<12-hex key id>.json` +# (project IDs the operator named themselves, no GCP suffix) +# Pinning the trailing key-id to exactly 12 hex chars keeps legitimate +# config files (`tsconfig-base.json`, `eslint-config.json`, etc.) out +# of the match while still catching every key the console emits. +**/*-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].json +**/*-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].json + # Temp tmp/ temp/ diff --git a/.husky/pre-commit b/.husky/pre-commit index 066f5454..16cf4232 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -4,6 +4,47 @@ set -e REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$REPO_ROOT" +# Bun version pin guard. CI's Docker image uses the version declared in +# `package.json`'s `packageManager` field (currently bun@1.3.13). bun +# lockfiles are not stable across major-minor versions — a lockfile +# generated by an older local bun will pass `--frozen-lockfile` here +# but fail in Docker. Before doing anything else, refuse to commit +# from a mismatched bun. +# +# The guard must be fail-closed: if `node` isn't on PATH or the +# package.json read fails, do not silently bypass — the whole point of +# the gate is preventing lockfile drift, and a stealth bypass defeats it. +# +# Use `bun -e` instead of `node -p`. Bun is the project's required +# runtime (the gate's whole purpose is to enforce a pinned bun +# version), so requiring node here just to parse the JSON would add a +# second mandatory toolchain. `bun -e` reads JSON via the same +# require() shim Node does and exits with the parse error if the file +# is malformed, matching the previous failure mode without the extra +# dep. +if ! command -v bun >/dev/null 2>&1; then + echo "❌ pre-commit gate: \`bun\` not on PATH — required to read packageManager pin from package.json." + echo " install bun (https://bun.sh) and re-commit." + exit 1 +fi +EXPECTED_BUN="$(bun -e "console.log(require('./package.json').packageManager.split('@')[1])")" +if [ -z "$EXPECTED_BUN" ]; then + echo "❌ pre-commit gate: could not read \`packageManager\` from package.json." + echo " the bun version pin is missing or malformed — fix package.json before committing." + exit 1 +fi +ACTUAL_BUN="$(bun --version 2>/dev/null || echo unknown)" +if [ "$EXPECTED_BUN" != "$ACTUAL_BUN" ]; then + echo "❌ bun version mismatch:" + echo " package.json packageManager: bun@$EXPECTED_BUN" + echo " local bun --version: $ACTUAL_BUN" + echo " run \`bun upgrade\` (or install bun@$EXPECTED_BUN) and re-commit." + echo " (Lockfiles drift across bun versions — CI's Docker uses" + echo " the pinned version and will fail with" + echo " 'lockfile had changes, but lockfile is frozen' otherwise.)" + exit 1 +fi + # Paths-aware kit pre-commit gate. Only runs when staged changes touch # packages/kit/**, so unrelated edits to apple/google/gql/docs/libraries # aren't blocked. @@ -57,6 +98,35 @@ if git diff --cached --name-only --diff-filter=ACMR | grep -q '^packages/kit/'; bun run --filter @hyodotdev/openiap-kit smoke:server fi +# Paths-aware Flutter analyze. Triggers on any libraries/flutter_inapp_purchase +# edit. Catches the `ambiguous_export` class of failure that took out +# CI on PR #124 — locally `flutter analyze` runs in <2s with a warm +# pub cache. +if git diff --cached --name-only --diff-filter=ACMR \ + | grep -qE '^libraries/flutter_inapp_purchase/(lib|test)/'; then + if command -v flutter >/dev/null 2>&1; then + echo "🐦 flutter-touched commit — running flutter analyze…" + (cd libraries/flutter_inapp_purchase && flutter analyze) + else + echo "⚠️ flutter not on PATH — skipping flutter analyze (CI will catch any issues)." + fi +fi + +# Paths-aware KMP compile check. Uses the existing `:library:compileDebugKotlinAndroid` +# task because that's the same target CI runs. With a warm gradle daemon +# this finishes in 5-10s; first run after `./gradlew --stop` can take 30-40s. +# Catches the redeclaration / interface-method-missing class of failure +# that hit PR #124. +if git diff --cached --name-only --diff-filter=ACMR \ + | grep -qE '^(libraries/kmp-iap/library/src|packages/gql/src/)' ; then + if [ -x libraries/kmp-iap/gradlew ]; then + echo "🎯 kmp/gql-touched commit — running ./gradlew :library:compileDebugKotlinAndroid…" + (cd libraries/kmp-iap && ./gradlew :library:compileDebugKotlinAndroid -q) + else + echo "⚠️ libraries/kmp-iap/gradlew not executable — skipping KMP compile." + fi +fi + # Paths-aware docs typecheck. The kit integration brought React 19 into # the workspace alongside docs's React 18, which previously caused # @types/react hoisting to break docs's tsc only in CI. Both are now on diff --git a/bun.lock b/bun.lock index c6313903..9ea5ed61 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 1, "workspaces": { "": { "name": "@hyodotdev/openiap", @@ -90,6 +91,7 @@ "antd": "^6.1.0", "clsx": "^2.1.1", "convex": "^1.29.2", + "google-auth-library": "^10.6.2", "googleapis": "^157.0.0", "hono": "^4.9.9", "hono-openapi": "^1.1.0", @@ -141,6 +143,22 @@ "vitest": "^4", }, }, + "packages/mcp-server": { + "name": "@hyodotdev/openiap-mcp-server", + "version": "0.1.0", + "bin": { + "openiap-mcp": "./dist/index.js", + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", + "zod": "^3.23.8", + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.2", + "vitest": "^4.1.5", + }, + }, }, "overrides": { "csstype": "3.2.3", @@ -166,7 +184,7 @@ "@apple/app-store-server-library": ["@apple/app-store-server-library@1.6.0", "", { "dependencies": { "@types/jsonwebtoken": "^9.0.5", "@types/jsrsasign": "^10.5.12", "@types/node": "^22.7.5", "@types/node-fetch": "^2.6.3", "base64url": "^3.0.1", "jsonwebtoken": "^9.0.2", "jsrsasign": "^11.0.0", "node-fetch": "^2.7.0" } }, "sha512-CmwCXLtkR6RdYjJjuiV2V7RNN6cJLeI7NoZtuDDhalM4/6MfNsZLrTyZvDWKWJsArh0LKoiplK+cc5+PCkwKUQ=="], - "@ardatan/relay-compiler": ["@ardatan/relay-compiler@12.0.3", "", { "dependencies": { "@babel/generator": "^7.26.10", "@babel/parser": "^7.26.10", "@babel/runtime": "^7.26.10", "chalk": "^4.0.0", "fb-watchman": "^2.0.0", "immutable": "~3.7.6", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "relay-runtime": "12.0.0", "signedsource": "^1.0.0" }, "peerDependencies": { "graphql": "*" }, "bin": { "relay-compiler": "bin/relay-compiler" } }, "sha512-mBDFOGvAoVlWaWqs3hm1AciGHSQE1rqFc/liZTyYz/Oek9yZdT5H26pH2zAFuEiTiBVPPyMuqf5VjOFPI2DGsQ=="], + "@ardatan/relay-compiler": ["@ardatan/relay-compiler@13.0.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "immutable": "^5.1.5", "invariant": "^2.2.4" }, "peerDependencies": { "graphql": "*" } }, "sha512-afG3YPwuSA0E5foouZusz5GlXKs74dObv4cuWyLyfKsYFj2r7oGRNB28v18HvwuLSQtQFCi+DpIe0TZkgQDYyg=="], "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], @@ -178,35 +196,35 @@ "@auth/core": ["@auth/core@0.41.2", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w=="], - "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@babel/compat-data": ["@babel/compat-data@7.28.4", "", {}, "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw=="], + "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], - "@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="], + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - "@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="], + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="], + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], - "@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], - "@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="], + "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], - "@babel/plugin-syntax-import-assertions": ["@babel/plugin-syntax-import-assertions@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg=="], + "@babel/plugin-syntax-import-assertions": ["@babel/plugin-syntax-import-assertions@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw=="], "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], @@ -214,11 +232,11 @@ "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], - "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - "@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/types": "^7.28.4", "debug": "^4.3.1" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="], + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - "@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="], + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], @@ -248,7 +266,7 @@ "@emotion/unitless": ["@emotion/unitless@0.7.5", "", {}, "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="], - "@envelop/core": ["@envelop/core@5.3.2", "", { "dependencies": { "@envelop/instrumentation": "^1.0.0", "@envelop/types": "^5.2.1", "@whatwg-node/promise-helpers": "^1.2.4", "tslib": "^2.5.0" } }, "sha512-06Mu7fmyKzk09P2i2kHpGfItqLLgCq7uO5/nX4fc/iHMplWPNuAx4iYR+WXUQoFHDnP6EUbceQNQ5iyeMz9f3g=="], + "@envelop/core": ["@envelop/core@5.5.1", "", { "dependencies": { "@envelop/instrumentation": "^1.0.0", "@envelop/types": "^5.2.1", "@whatwg-node/promise-helpers": "^1.2.4", "tslib": "^2.5.0" } }, "sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw=="], "@envelop/instrumentation": ["@envelop/instrumentation@1.0.0", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.2.1", "tslib": "^2.5.0" } }, "sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw=="], @@ -306,23 +324,23 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - "@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="], + "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.4.0", "", { "dependencies": { "@eslint/core": "^0.16.0" } }, "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], - "@eslint/core": ["@eslint/core@0.16.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q=="], + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], - "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="], - "@eslint/js": ["@eslint/js@9.37.0", "", {}, "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg=="], + "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], - "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="], + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.0", "", { "dependencies": { "@eslint/core": "^0.16.0", "levn": "^0.4.1" } }, "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], "@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="], @@ -330,85 +348,89 @@ "@fastify/otel": ["@fastify/otel@0.18.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.212.0", "@opentelemetry/semantic-conventions": "^1.28.0", "minimatch": "^10.2.4" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA=="], - "@graphql-codegen/add": ["@graphql-codegen/add@6.0.0", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-biFdaURX0KTwEJPQ1wkT6BRgNasqgQ5KbCI1a3zwtLtO7XTo7/vKITPylmiU27K5DSOWYnY/1jfSqUAEBuhZrQ=="], + "@graphql-codegen/add": ["@graphql-codegen/add@6.0.1", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.3.0", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-MSylSekjpVWbOBw2A/2ssk1fPY54sYb6Qk2C4AX5u7s2R+2pMQ9ws7DTXo8VU9qwTgWwVp6vGfdQ0AMpAn4Iug=="], - "@graphql-codegen/cli": ["@graphql-codegen/cli@6.0.0", "", { "dependencies": { "@babel/generator": "^7.18.13", "@babel/template": "^7.18.10", "@babel/types": "^7.18.13", "@graphql-codegen/client-preset": "^5.0.0", "@graphql-codegen/core": "^5.0.0", "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-tools/apollo-engine-loader": "^8.0.0", "@graphql-tools/code-file-loader": "^8.0.0", "@graphql-tools/git-loader": "^8.0.0", "@graphql-tools/github-loader": "^8.0.0", "@graphql-tools/graphql-file-loader": "^8.0.0", "@graphql-tools/json-file-loader": "^8.0.0", "@graphql-tools/load": "^8.1.0", "@graphql-tools/url-loader": "^8.0.0", "@graphql-tools/utils": "^10.0.0", "@inquirer/prompts": "^7.8.2", "@whatwg-node/fetch": "^0.10.0", "chalk": "^4.1.0", "cosmiconfig": "^9.0.0", "debounce": "^2.0.0", "detect-indent": "^6.0.0", "graphql-config": "^5.1.1", "is-glob": "^4.0.1", "jiti": "^2.3.0", "json-to-pretty-yaml": "^1.2.2", "listr2": "^9.0.0", "log-symbols": "^4.0.0", "micromatch": "^4.0.5", "shell-quote": "^1.7.3", "string-env-interpolation": "^1.0.1", "ts-log": "^2.2.3", "tslib": "^2.4.0", "yaml": "^2.3.1", "yargs": "^17.0.0" }, "peerDependencies": { "@parcel/watcher": "^2.1.0", "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["@parcel/watcher"], "bin": { "gql-gen": "cjs/bin.js", "graphql-codegen": "cjs/bin.js", "graphql-code-generator": "cjs/bin.js", "graphql-codegen-esm": "esm/bin.js" } }, "sha512-tvchLVCMtorDE+UwgQbrjyaQK16GCZA+QomTxZazRx64ixtgmbEiQV7GhCBy0y0Bo7/tcTJb6sy9G/TL/BgiOg=="], + "@graphql-codegen/cli": ["@graphql-codegen/cli@6.3.1", "", { "dependencies": { "@babel/generator": "^7.18.13", "@babel/template": "^7.18.10", "@babel/types": "^7.18.13", "@graphql-codegen/client-preset": "^5.3.0", "@graphql-codegen/core": "^5.0.2", "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-tools/apollo-engine-loader": "^8.0.28", "@graphql-tools/code-file-loader": "^8.1.28", "@graphql-tools/git-loader": "^8.0.32", "@graphql-tools/github-loader": "^9.0.6", "@graphql-tools/graphql-file-loader": "^8.1.11", "@graphql-tools/json-file-loader": "^8.0.26", "@graphql-tools/load": "^8.1.8", "@graphql-tools/merge": "^9.0.6", "@graphql-tools/url-loader": "^9.0.6", "@graphql-tools/utils": "^11.0.0", "@inquirer/prompts": "^7.8.2", "@whatwg-node/fetch": "^0.10.0", "chalk": "^4.1.0", "cosmiconfig": "^9.0.0", "debounce": "^2.0.0", "detect-indent": "^6.0.0", "graphql-config": "^5.1.6", "is-glob": "^4.0.1", "jiti": "^2.3.0", "json-to-pretty-yaml": "^1.2.2", "listr2": "^9.0.0", "log-symbols": "^4.0.0", "micromatch": "^4.0.5", "shell-quote": "^1.7.3", "string-env-interpolation": "^1.0.1", "ts-log": "^2.2.3", "tslib": "^2.4.0", "yaml": "^2.3.1", "yargs": "^17.0.0" }, "peerDependencies": { "@parcel/watcher": "^2.1.0", "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["@parcel/watcher"], "bin": { "gql-gen": "cjs/bin.js", "graphql-codegen": "cjs/bin.js", "graphql-codegen-esm": "esm/bin.js", "graphql-code-generator": "cjs/bin.js" } }, "sha512-I5KkyX1SgQZPojMeQTRydB6fml4cysZq/mIdhNW4rmqdoOcTgdMPq1Tl+wtRp1VpBAOrBazJUJh1nAqJMMSPIQ=="], - "@graphql-codegen/client-preset": ["@graphql-codegen/client-preset@5.1.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/template": "^7.20.7", "@graphql-codegen/add": "^6.0.0", "@graphql-codegen/gql-tag-operations": "5.0.2", "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-codegen/typed-document-node": "^6.0.2", "@graphql-codegen/typescript": "^5.0.2", "@graphql-codegen/typescript-operations": "^5.0.2", "@graphql-codegen/visitor-plugin-common": "^6.1.0", "@graphql-tools/documents": "^1.0.0", "@graphql-tools/utils": "^10.0.0", "@graphql-typed-document-node/core": "3.2.0", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "graphql-sock": "^1.0.0" }, "optionalPeers": ["graphql-sock"] }, "sha512-MYMy9dIlAgT3q1U8WUys6Y8yt/T9WLsm1DczRtrCpV5N11v4Rlg3hGWQmEvhJtBbWxgzfYoHZHb0TohtbLkJ+g=="], + "@graphql-codegen/client-preset": ["@graphql-codegen/client-preset@5.3.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/template": "^7.20.7", "@graphql-codegen/add": "^6.0.1", "@graphql-codegen/gql-tag-operations": "5.2.0", "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-codegen/typed-document-node": "^6.1.8", "@graphql-codegen/typescript": "^5.0.10", "@graphql-codegen/typescript-operations": "^5.1.0", "@graphql-codegen/visitor-plugin-common": "^6.3.0", "@graphql-tools/documents": "^1.0.0", "@graphql-tools/utils": "^11.0.0", "@graphql-typed-document-node/core": "3.2.0", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "graphql-sock": "^1.0.0" }, "optionalPeers": ["graphql-sock"] }, "sha512-K9FON+j7qyxAUDuSGqI3ofb7lWTBs16oPTYpu14lhdL4DKZQSHLyc8EMYU9e3KcyQ/13gU/d6culOppzAuexLA=="], - "@graphql-codegen/core": ["@graphql-codegen/core@5.0.0", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-tools/schema": "^10.0.0", "@graphql-tools/utils": "^10.0.0", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-vLTEW0m8LbE4xgRwbFwCdYxVkJ1dBlVJbQyLb9Q7bHnVFgHAP982Xo8Uv7FuPBmON+2IbTjkCqhFLHVZbqpvjQ=="], + "@graphql-codegen/core": ["@graphql-codegen/core@5.0.2", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-tools/schema": "^10.0.0", "@graphql-tools/utils": "^11.0.0", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-7RX0wwjoWPlLG/tUmpaTK91ZZqHcACNWpRL0nGnnJaJrORie9pgmX8JPrcwBgYiHSC+3ERo9xY91RFPem/VrpQ=="], - "@graphql-codegen/gql-tag-operations": ["@graphql-codegen/gql-tag-operations@5.0.2", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-codegen/visitor-plugin-common": "6.1.0", "@graphql-tools/utils": "^10.0.0", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-iK+LFGv4ihHKeerADFPTL7Iq4iNr+J1jm2+GUMtwTSAL4nGk+BdfyruV7eR53R7Des8NFdI+9hBzKbbob7VwGQ=="], + "@graphql-codegen/gql-tag-operations": ["@graphql-codegen/gql-tag-operations@5.2.0", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-codegen/visitor-plugin-common": "^6.3.0", "@graphql-tools/utils": "^11.0.0", "auto-bind": "~4.0.0", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-B9gtJ4ziqpIv+7mHqwjtpYLFOuv0GmmRGpNDoWKM2VIx4OQqgI84d6OHKYCVeO7yu3mUr0QPvUgkSyuLVrdukA=="], - "@graphql-codegen/plugin-helpers": ["@graphql-codegen/plugin-helpers@6.0.0", "", { "dependencies": { "@graphql-tools/utils": "^10.0.0", "change-case-all": "1.0.15", "common-tags": "1.8.2", "import-from": "4.0.0", "lodash": "~4.17.0", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-Z7P89vViJvQakRyMbq/JF2iPLruRFOwOB6IXsuSvV/BptuuEd7fsGPuEf8bdjjDxUY0pJZnFN8oC7jIQ8p9GKA=="], + "@graphql-codegen/plugin-helpers": ["@graphql-codegen/plugin-helpers@6.3.0", "", { "dependencies": { "@graphql-tools/utils": "^11.0.0", "change-case-all": "1.0.15", "common-tags": "1.8.2", "import-from": "4.0.0", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-Auc+/B7okDx9+pVgLVliZtZLYh6iltWXlnzzM+bRE+zh1T4r3hKbnr8xAmtT937ArfSgk5GHcQHr8LfPYnrRBg=="], - "@graphql-codegen/schema-ast": ["@graphql-codegen/schema-ast@5.0.0", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-tools/utils": "^10.0.0", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-jn7Q3PKQc0FxXjbpo9trxzlz/GSFQWxL042l0iC8iSbM/Ar+M7uyBwMtXPsev/3Razk+osQyreghIz0d2+6F7Q=="], + "@graphql-codegen/schema-ast": ["@graphql-codegen/schema-ast@5.0.2", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-tools/utils": "^11.0.0", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-jl1F/9IjRkJisEb9B0ayG4QGqYlPldLRy8ojDdmL9NE1NsdB5ROfxQnSqyC3g+wuvBhWX7kZgMRQYn3RU1I5bA=="], - "@graphql-codegen/typed-document-node": ["@graphql-codegen/typed-document-node@6.0.2", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-codegen/visitor-plugin-common": "6.1.0", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-nqcD23F87jLPQ1P2jJaepNAa4SY8Xy2soacPyQMwvxWtbRSXlg/LBUjtbEkCaU2SuLoa4L3w8VPuGoQ3EWUzeg=="], + "@graphql-codegen/typed-document-node": ["@graphql-codegen/typed-document-node@6.1.8", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-codegen/visitor-plugin-common": "^6.3.0", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-+qDdiJSQ7Ol+vpLMAH8ZJok50CvlYxA6seQ7cwEa3emXt8MmH5hh3zdc9unQlPc7bynoJHRCgoKk7E0B7hry0w=="], - "@graphql-codegen/typescript": ["@graphql-codegen/typescript@5.0.2", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-codegen/schema-ast": "^5.0.0", "@graphql-codegen/visitor-plugin-common": "6.1.0", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-OJYXpS9SRf4VFzqu3ZH/RmTftGhAVTCmscH63iPlvTlCT8NBmpSHdZ875AEa38LugdL8XgUcGsI3pprP3e5j/w=="], + "@graphql-codegen/typescript": ["@graphql-codegen/typescript@5.0.10", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-codegen/schema-ast": "^5.0.2", "@graphql-codegen/visitor-plugin-common": "^6.3.0", "auto-bind": "~4.0.0", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-Pa8OFmL9TdhEYnLYJLYA9EhP8eEeivP/YDYq4Nb8LQaL7GXm4TGX8zELYaCM9Fu8M3iZb7iQGMt7qc+1lXz8XQ=="], - "@graphql-codegen/typescript-operations": ["@graphql-codegen/typescript-operations@5.0.2", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-codegen/typescript": "^5.0.2", "@graphql-codegen/visitor-plugin-common": "6.1.0", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "graphql-sock": "^1.0.0" }, "optionalPeers": ["graphql-sock"] }, "sha512-i2nSJ5a65H+JgXwWvEuYehVYUImIvrHk3PTs+Fcj+OjZFvDl2qBziIhr6shCjV0KH9IZ6Y+1v4TzkxZr/+XFjA=="], + "@graphql-codegen/typescript-operations": ["@graphql-codegen/typescript-operations@5.1.0", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-codegen/typescript": "^5.0.10", "@graphql-codegen/visitor-plugin-common": "^6.3.0", "auto-bind": "~4.0.0", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "graphql-sock": "^1.0.0" }, "optionalPeers": ["graphql-sock"] }, "sha512-JlmjbFl0EnsfMDIYvTE1Q0kAOrntVEZ+ZfBqWTP91g4e0F/TzuwJ/V4tiFmeDf5dx/rf9AK4VkPehIdxu7TYhw=="], - "@graphql-codegen/visitor-plugin-common": ["@graphql-codegen/visitor-plugin-common@6.1.0", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-tools/optimize": "^2.0.0", "@graphql-tools/relay-operation-optimizer": "^7.0.0", "@graphql-tools/utils": "^10.0.0", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "dependency-graph": "^1.0.0", "graphql-tag": "^2.11.0", "parse-filepath": "^1.0.2", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-AvGO1pe+b/kAa7+WBDlNDXOruRZWv/NnhLHgTggiW2XWRv33biuzg4cF1UTdpR2jmESZzJU4kXngLLX8RYJWLA=="], + "@graphql-codegen/visitor-plugin-common": ["@graphql-codegen/visitor-plugin-common@6.3.0", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-tools/optimize": "^2.0.0", "@graphql-tools/relay-operation-optimizer": "^7.1.1", "@graphql-tools/utils": "^11.0.0", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "dependency-graph": "^1.0.0", "graphql-tag": "^2.11.0", "parse-filepath": "^1.0.2", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-vGBoE+4huzZyNhyGSAhXAkdROHlwKxxuziZm4XtP1mxe7nuI+VgyOmXebafLijbmuDsptPXQN0C/htL54O8hrg=="], - "@graphql-hive/signal": ["@graphql-hive/signal@1.0.0", "", {}, "sha512-RiwLMc89lTjvyLEivZ/qxAC5nBHoS2CtsWFSOsN35sxG9zoo5Z+JsFHM8MlvmO9yt+MJNIyC5MLE1rsbOphlag=="], + "@graphql-hive/signal": ["@graphql-hive/signal@2.0.0", "", {}, "sha512-Pz8wB3K0iU6ae9S1fWfsmJX24CcGeTo6hE7T44ucmV/ALKRj+bxClmqrYcDT7v3f0d12Rh4FAXBb6gon+WkDpQ=="], - "@graphql-tools/apollo-engine-loader": ["@graphql-tools/apollo-engine-loader@8.0.22", "", { "dependencies": { "@graphql-tools/utils": "^10.9.1", "@whatwg-node/fetch": "^0.10.0", "sync-fetch": "0.6.0-2", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-ssD2wNxeOTRcUEkuGcp0KfZAGstL9YLTe/y3erTDZtOs2wL1TJESw8NVAp+3oUHPeHKBZQB4Z6RFEbPgMdT2wA=="], + "@graphql-tools/apollo-engine-loader": ["@graphql-tools/apollo-engine-loader@8.0.30", "", { "dependencies": { "@graphql-tools/utils": "^11.1.0", "@whatwg-node/fetch": "^0.10.13", "sync-fetch": "0.6.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-hUydKGGECrWloERMmfoMzHZi12X99AM9geCGF5XVsv4iMRl/Iyuet24th4kC9bZ8MlAdCwAwtUsCyv9uRfYwSA=="], - "@graphql-tools/batch-execute": ["@graphql-tools/batch-execute@9.0.19", "", { "dependencies": { "@graphql-tools/utils": "^10.9.1", "@whatwg-node/promise-helpers": "^1.3.0", "dataloader": "^2.2.3", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-VGamgY4PLzSx48IHPoblRw0oTaBa7S26RpZXt0Y4NN90ytoE0LutlpB2484RbkfcTjv9wa64QD474+YP1kEgGA=="], + "@graphql-tools/batch-execute": ["@graphql-tools/batch-execute@10.0.8", "", { "dependencies": { "@graphql-tools/utils": "^11.0.0", "@whatwg-node/promise-helpers": "^1.3.2", "dataloader": "^2.2.3", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-Kobt37qrVTFhX4HUK5/vPgMXFw/5f97AzmAlfmDBSRh/GnoAmLKCb48FrEI3gdeIwZB2fEhVHJyDqsojldnLQA=="], - "@graphql-tools/code-file-loader": ["@graphql-tools/code-file-loader@8.1.22", "", { "dependencies": { "@graphql-tools/graphql-tag-pluck": "8.3.21", "@graphql-tools/utils": "^10.9.1", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-FSka29kqFkfFmw36CwoQ+4iyhchxfEzPbXOi37lCEjWLHudGaPkXc3RyB9LdmBxx3g3GHEu43a5n5W8gfcrMdA=="], + "@graphql-tools/code-file-loader": ["@graphql-tools/code-file-loader@8.1.32", "", { "dependencies": { "@graphql-tools/graphql-tag-pluck": "8.3.31", "@graphql-tools/utils": "^11.1.0", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-gR5mNQjn0BugDL8a4A+ovS2KEvU52RNOGnbwiq9oWAEHiSv7iqJu77bpWARTzlE1ZFPK5MSQe9218+1t5PbXmQ=="], - "@graphql-tools/delegate": ["@graphql-tools/delegate@10.2.23", "", { "dependencies": { "@graphql-tools/batch-execute": "^9.0.19", "@graphql-tools/executor": "^1.4.9", "@graphql-tools/schema": "^10.0.25", "@graphql-tools/utils": "^10.9.1", "@repeaterjs/repeater": "^3.0.6", "@whatwg-node/promise-helpers": "^1.3.0", "dataloader": "^2.2.3", "dset": "^3.1.2", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-xrPtl7f1LxS+B6o+W7ueuQh67CwRkfl+UKJncaslnqYdkxKmNBB4wnzVcW8ZsRdwbsla/v43PtwAvSlzxCzq2w=="], + "@graphql-tools/delegate": ["@graphql-tools/delegate@12.0.14", "", { "dependencies": { "@graphql-tools/batch-execute": "^10.0.8", "@graphql-tools/executor": "^1.4.13", "@graphql-tools/schema": "^10.0.29", "@graphql-tools/utils": "^11.0.0", "@repeaterjs/repeater": "^3.0.6", "@whatwg-node/promise-helpers": "^1.3.2", "dataloader": "^2.2.3", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-/xCDM8zlCk1Lccww9asOIpxna9IFpIlol4yGsBD9Y2+3/Zu5k4/HzDC8LKJtw5MxdG+uJN1l9nRepr4GeBC4kA=="], "@graphql-tools/documents": ["@graphql-tools/documents@1.0.1", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-aweoMH15wNJ8g7b2r4C4WRuJxZ0ca8HtNO54rkye/3duxTkW4fGBEutCx03jCIr5+a1l+4vFJNP859QnAVBVCA=="], - "@graphql-tools/executor": ["@graphql-tools/executor@1.4.9", "", { "dependencies": { "@graphql-tools/utils": "^10.9.1", "@graphql-typed-document-node/core": "^3.2.0", "@repeaterjs/repeater": "^3.0.4", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-SAUlDT70JAvXeqV87gGzvDzUGofn39nvaVcVhNf12Dt+GfWHtNNO/RCn/Ea4VJaSLGzraUd41ObnN3i80EBU7w=="], + "@graphql-tools/executor": ["@graphql-tools/executor@1.5.3", "", { "dependencies": { "@graphql-tools/utils": "^11.1.0", "@graphql-typed-document-node/core": "^3.2.0", "@repeaterjs/repeater": "^3.0.4", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mgBFC0bsrZPZLu9EnydpMnAuQ8Iiq0CEbUcsmvXsm2/iYektGHDN/+bmb7hicA6dWZtdPfklYJmr21WD0GnOfA=="], - "@graphql-tools/executor-common": ["@graphql-tools/executor-common@0.0.4", "", { "dependencies": { "@envelop/core": "^5.2.3", "@graphql-tools/utils": "^10.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-SEH/OWR+sHbknqZyROCFHcRrbZeUAyjCsgpVWCRjqjqRbiJiXq6TxNIIOmpXgkrXWW/2Ev4Wms6YSGJXjdCs6Q=="], + "@graphql-tools/executor-common": ["@graphql-tools/executor-common@1.0.6", "", { "dependencies": { "@envelop/core": "^5.4.0", "@graphql-tools/utils": "^11.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-23/K5C+LSlHDI0mj2SwCJ33RcELCcyDUgABm1Z8St7u/4Z5+95i925H/NAjUyggRjiaY8vYtNiMOPE49aPX1sg=="], - "@graphql-tools/executor-graphql-ws": ["@graphql-tools/executor-graphql-ws@2.0.7", "", { "dependencies": { "@graphql-tools/executor-common": "^0.0.6", "@graphql-tools/utils": "^10.9.1", "@whatwg-node/disposablestack": "^0.0.6", "graphql-ws": "^6.0.6", "isomorphic-ws": "^5.0.0", "tslib": "^2.8.1", "ws": "^8.18.3" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-J27za7sKF6RjhmvSOwOQFeNhNHyP4f4niqPnerJmq73OtLx9Y2PGOhkXOEB0PjhvPJceuttkD2O1yMgEkTGs3Q=="], + "@graphql-tools/executor-graphql-ws": ["@graphql-tools/executor-graphql-ws@3.1.5", "", { "dependencies": { "@graphql-tools/executor-common": "^1.0.6", "@graphql-tools/utils": "^11.0.0", "@whatwg-node/disposablestack": "^0.0.6", "graphql-ws": "^6.0.6", "isows": "^1.0.7", "tslib": "^2.8.1", "ws": "^8.18.3" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-WXRsfwu9AkrORD9nShrd61OwwxeQ5+eXYcABRR3XPONFIS8pWQfDJGGqxql9/227o/s0DV5SIfkBURb5Knzv+A=="], - "@graphql-tools/executor-http": ["@graphql-tools/executor-http@1.3.3", "", { "dependencies": { "@graphql-hive/signal": "^1.0.0", "@graphql-tools/executor-common": "^0.0.4", "@graphql-tools/utils": "^10.8.1", "@repeaterjs/repeater": "^3.0.4", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/fetch": "^0.10.4", "@whatwg-node/promise-helpers": "^1.3.0", "meros": "^1.2.1", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-LIy+l08/Ivl8f8sMiHW2ebyck59JzyzO/yF9SFS4NH6MJZUezA1xThUXCDIKhHiD56h/gPojbkpcFvM2CbNE7A=="], + "@graphql-tools/executor-http": ["@graphql-tools/executor-http@3.2.1", "", { "dependencies": { "@graphql-hive/signal": "^2.0.0", "@graphql-tools/executor-common": "^1.0.6", "@graphql-tools/utils": "^11.0.0", "@repeaterjs/repeater": "^3.0.4", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/fetch": "^0.10.13", "@whatwg-node/promise-helpers": "^1.3.2", "meros": "^1.3.2", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-53i0TYO0cznIlZDJcnq4gQ6SOZ8efGgCDV33MYh6oqEapcp36tCMEVnVGVxcX5qRRyNHkqTY6hkA+/AyK9kicQ=="], - "@graphql-tools/executor-legacy-ws": ["@graphql-tools/executor-legacy-ws@1.1.19", "", { "dependencies": { "@graphql-tools/utils": "^10.9.1", "@types/ws": "^8.0.0", "isomorphic-ws": "^5.0.0", "tslib": "^2.4.0", "ws": "^8.17.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-bEbv/SlEdhWQD0WZLUX1kOenEdVZk1yYtilrAWjRUgfHRZoEkY9s+oiqOxnth3z68wC2MWYx7ykkS5hhDamixg=="], + "@graphql-tools/executor-legacy-ws": ["@graphql-tools/executor-legacy-ws@1.1.28", "", { "dependencies": { "@graphql-tools/utils": "^11.1.0", "@types/ws": "^8.0.0", "isomorphic-ws": "^5.0.0", "tslib": "^2.4.0", "ws": "^8.20.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-O4uj93GG9iUb3s32eyhUohvyfA8mLhN8FvGzEdK628hFQPhZN75yurtVFrR08DHex71mQ3wYCCFkErpwdJbDDQ=="], - "@graphql-tools/git-loader": ["@graphql-tools/git-loader@8.0.26", "", { "dependencies": { "@graphql-tools/graphql-tag-pluck": "8.3.21", "@graphql-tools/utils": "^10.9.1", "is-glob": "4.0.3", "micromatch": "^4.0.8", "tslib": "^2.4.0", "unixify": "^1.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-0g+9eng8DaT4ZmZvUmPgjLTgesUa6M8xrDjNBltRldZkB055rOeUgJiKmL6u8PjzI5VxkkVsn0wtAHXhDI2UXQ=="], + "@graphql-tools/git-loader": ["@graphql-tools/git-loader@8.0.36", "", { "dependencies": { "@graphql-tools/graphql-tag-pluck": "8.3.31", "@graphql-tools/utils": "^11.1.0", "is-glob": "4.0.3", "micromatch": "^4.0.8", "tslib": "^2.4.0", "unixify": "^1.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-PDDakesRu8FJYHJLf9/gkTweh8M19Bymz9i+vOlk9OTs9XmNcCqKM+1S610KX2AodvuBFz/xbesjTtTJIppLPg=="], - "@graphql-tools/github-loader": ["@graphql-tools/github-loader@8.0.22", "", { "dependencies": { "@graphql-tools/executor-http": "^1.1.9", "@graphql-tools/graphql-tag-pluck": "^8.3.21", "@graphql-tools/utils": "^10.9.1", "@whatwg-node/fetch": "^0.10.0", "@whatwg-node/promise-helpers": "^1.0.0", "sync-fetch": "0.6.0-2", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-uQ4JNcNPsyMkTIgzeSbsoT9hogLjYrZooLUYd173l5eUGUi49EAcsGdiBCKaKfEjanv410FE8hjaHr7fjSRkJw=="], + "@graphql-tools/github-loader": ["@graphql-tools/github-loader@9.1.2", "", { "dependencies": { "@graphql-tools/executor-http": "^3.2.1", "@graphql-tools/graphql-tag-pluck": "^8.3.31", "@graphql-tools/utils": "^11.1.0", "@whatwg-node/fetch": "^0.10.13", "@whatwg-node/promise-helpers": "^1.0.0", "sync-fetch": "0.6.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-jhRJncj9Wkr1Cd8Mo3QI2oG6fTw5ILr1/OXcHIqx744NBj8pPwQBXmQzZqh7MXxbekl2EAcum7SJIjq1HpYcPA=="], - "@graphql-tools/graphql-file-loader": ["@graphql-tools/graphql-file-loader@8.1.2", "", { "dependencies": { "@graphql-tools/import": "7.1.2", "@graphql-tools/utils": "^10.9.1", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-VB6ttpwkqCu0KsA1/Wmev4qsu05Qfw49kgVSKkPjuyDQfVaqtr9ewEQRkX5CqnqHGEeLl6sOlNGEMM5fCVMWGQ=="], + "@graphql-tools/graphql-file-loader": ["@graphql-tools/graphql-file-loader@8.1.14", "", { "dependencies": { "@graphql-tools/import": "^7.1.14", "@graphql-tools/utils": "^11.1.0", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-CfAcsSEVkkHfEXLFzrd5rUYpcQEGWNV8lfc1Tb1p5m9HnYICzDDH08I5V33iMrEDza3GuujjjRBYqplBkqwIow=="], - "@graphql-tools/graphql-tag-pluck": ["@graphql-tools/graphql-tag-pluck@8.3.21", "", { "dependencies": { "@babel/core": "^7.26.10", "@babel/parser": "^7.26.10", "@babel/plugin-syntax-import-assertions": "^7.26.0", "@babel/traverse": "^7.26.10", "@babel/types": "^7.26.10", "@graphql-tools/utils": "^10.9.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-TJhELNvR1tmghXMi6HVKp/Swxbx1rcSp/zdkuJZT0DCM3vOY11FXY6NW3aoxumcuYDNN3jqXcCPKstYGFPi5GQ=="], + "@graphql-tools/graphql-tag-pluck": ["@graphql-tools/graphql-tag-pluck@8.3.31", "", { "dependencies": { "@babel/core": "^7.28.6", "@babel/parser": "^7.29.2", "@babel/plugin-syntax-import-assertions": "^7.26.0", "@babel/traverse": "^7.26.10", "@babel/types": "^7.26.10", "@graphql-tools/utils": "^11.1.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-ema2RRPZGj8TKruNElyDBHVCNFMxioGIVfLBuiA+GdfmRGt95b/i7Uksnj4EwItA6MCmhxokxZoa/fl6mJt3tw=="], - "@graphql-tools/import": ["@graphql-tools/import@7.1.2", "", { "dependencies": { "@graphql-tools/utils": "^10.9.1", "@theguild/federation-composition": "^0.20.1", "resolve-from": "5.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-+tlNQbLEqAA4LdWoLwM1tckx95lo8WIKd8vhj99b9rLwN/KfLwHWzdS3jnUFK7+99vmHmN1oE5v5zmqJz0MTKw=="], + "@graphql-tools/import": ["@graphql-tools/import@7.1.14", "", { "dependencies": { "@graphql-tools/utils": "^11.1.0", "resolve-from": "5.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-aqLcu04aEidszbXM6M0PWWL8bP17eX9sxXwjYWpglLvIRd4NFqb3C9QzBY8pleqXNMtWqXktlm9BQjevgSrirQ=="], - "@graphql-tools/json-file-loader": ["@graphql-tools/json-file-loader@8.0.20", "", { "dependencies": { "@graphql-tools/utils": "^10.9.1", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-5v6W+ZLBBML5SgntuBDLsYoqUvwfNboAwL6BwPHi3z/hH1f8BS9/0+MCW9OGY712g7E4pc3y9KqS67mWF753eA=="], + "@graphql-tools/json-file-loader": ["@graphql-tools/json-file-loader@8.0.28", "", { "dependencies": { "@graphql-tools/utils": "^11.1.0", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-qgCsSkPArnjlNkcYpgGKiXxCTNkrAT9E+l1LhR+Por2jTlKBBeZ8stortkQ/PNDDjuL0WPrLQmHKhNPHabnB3A=="], - "@graphql-tools/load": ["@graphql-tools/load@8.1.2", "", { "dependencies": { "@graphql-tools/schema": "^10.0.25", "@graphql-tools/utils": "^10.9.1", "p-limit": "3.1.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-WhDPv25/jRND+0uripofMX0IEwo6mrv+tJg6HifRmDu8USCD7nZhufT0PP7lIcuutqjIQFyogqT70BQsy6wOgw=="], + "@graphql-tools/load": ["@graphql-tools/load@8.1.10", "", { "dependencies": { "@graphql-tools/schema": "^10.0.33", "@graphql-tools/utils": "^11.1.0", "p-limit": "3.1.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-hjcvfEFtwtc8vGi46wtpmGWadNzfEhzbjqinyFIZuIZPlR4aYdWQtqWtY/RMM4Ew4t1USkMNm6xrqC2TH1vCSA=="], - "@graphql-tools/merge": ["@graphql-tools/merge@9.1.1", "", { "dependencies": { "@graphql-tools/utils": "^10.9.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-BJ5/7Y7GOhTuvzzO5tSBFL4NGr7PVqTJY3KeIDlVTT8YLcTXtBR+hlrC3uyEym7Ragn+zyWdHeJ9ev+nRX1X2w=="], + "@graphql-tools/merge": ["@graphql-tools/merge@9.1.9", "", { "dependencies": { "@graphql-tools/utils": "^11.1.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-iHUWNjRHeQRYdgIMIuChThOwoKzA9vrzYeslgfBo5eUYEyHGZCoDPjAavssoYXLwstYt1dZj2J22jSzc2DrN0Q=="], "@graphql-tools/optimize": ["@graphql-tools/optimize@2.0.0", "", { "dependencies": { "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-nhdT+CRGDZ+bk68ic+Jw1OZ99YCDIKYA5AlVAnBHJvMawSx9YQqQAIj4refNc1/LRieGiuWvhbG3jvPVYho0Dg=="], - "@graphql-tools/relay-operation-optimizer": ["@graphql-tools/relay-operation-optimizer@7.0.21", "", { "dependencies": { "@ardatan/relay-compiler": "^12.0.3", "@graphql-tools/utils": "^10.9.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-vMdU0+XfeBh9RCwPqRsr3A05hPA3MsahFn/7OAwXzMySA5EVnSH5R4poWNs3h1a0yT0tDPLhxORhK7qJdSWj2A=="], + "@graphql-tools/relay-operation-optimizer": ["@graphql-tools/relay-operation-optimizer@7.1.4", "", { "dependencies": { "@ardatan/relay-compiler": "^13.0.1", "@graphql-tools/utils": "^11.1.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-cwOD/GEo/R//1uGCP0/urIxsMFoUgzkJVyMt9BDM2HhQhU6rSgH5l6lFukAFTJyPJVdyeOdYm2i0Jj5vYWbHTw=="], - "@graphql-tools/schema": ["@graphql-tools/schema@10.0.25", "", { "dependencies": { "@graphql-tools/merge": "^9.1.1", "@graphql-tools/utils": "^10.9.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-/PqE8US8kdQ7lB9M5+jlW8AyVjRGCKU7TSktuW3WNKSKmDO0MK1wakvb5gGdyT49MjAIb4a3LWxIpwo5VygZuw=="], + "@graphql-tools/schema": ["@graphql-tools/schema@10.0.33", "", { "dependencies": { "@graphql-tools/merge": "^9.1.9", "@graphql-tools/utils": "^11.1.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-O6P3RIftO0jafnSsFAqpjurUuUxJ43s/AdPVLQsBkI6y4Ic/tKm4C1Qm1KKQsCDTOxXPJClh/v3g7k7yLKCFBQ=="], - "@graphql-tools/url-loader": ["@graphql-tools/url-loader@8.0.33", "", { "dependencies": { "@graphql-tools/executor-graphql-ws": "^2.0.1", "@graphql-tools/executor-http": "^1.1.9", "@graphql-tools/executor-legacy-ws": "^1.1.19", "@graphql-tools/utils": "^10.9.1", "@graphql-tools/wrap": "^10.0.16", "@types/ws": "^8.0.0", "@whatwg-node/fetch": "^0.10.0", "@whatwg-node/promise-helpers": "^1.0.0", "isomorphic-ws": "^5.0.0", "sync-fetch": "0.6.0-2", "tslib": "^2.4.0", "ws": "^8.17.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-Fu626qcNHcqAj8uYd7QRarcJn5XZ863kmxsg1sm0fyjyfBJnsvC7ddFt6Hayz5kxVKfsnjxiDfPMXanvsQVBKw=="], + "@graphql-tools/url-loader": ["@graphql-tools/url-loader@9.1.2", "", { "dependencies": { "@graphql-tools/executor-graphql-ws": "^3.1.4", "@graphql-tools/executor-http": "^3.2.1", "@graphql-tools/executor-legacy-ws": "^1.1.28", "@graphql-tools/utils": "^11.1.0", "@graphql-tools/wrap": "^11.1.1", "@types/ws": "^8.0.0", "@whatwg-node/fetch": "^0.10.13", "@whatwg-node/promise-helpers": "^1.0.0", "isomorphic-ws": "^5.0.0", "sync-fetch": "0.6.0", "tslib": "^2.4.0", "ws": "^8.20.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-pVSiPrfWQKb3jq23Pl7EjbB2uv3tgZLnWo/axkmg4itAEZ5s/vV/jKa8P1HZzUnSVUTR+8tcEZVeNsUbzFCbkg=="], - "@graphql-tools/utils": ["@graphql-tools/utils@10.9.1", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "dset": "^3.1.4", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-B1wwkXk9UvU7LCBkPs8513WxOQ2H8Fo5p8HR1+Id9WmYE5+bd51vqN+MbrqvWczHCH2gwkREgHJN88tE0n1FCw=="], + "@graphql-tools/utils": ["@graphql-tools/utils@11.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag=="], - "@graphql-tools/wrap": ["@graphql-tools/wrap@10.1.4", "", { "dependencies": { "@graphql-tools/delegate": "^10.2.23", "@graphql-tools/schema": "^10.0.25", "@graphql-tools/utils": "^10.9.1", "@whatwg-node/promise-helpers": "^1.3.0", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-7pyNKqXProRjlSdqOtrbnFRMQAVamCmEREilOXtZujxY6kYit3tvWWSjUrcIOheltTffoRh7EQSjpy2JDCzasg=="], + "@graphql-tools/wrap": ["@graphql-tools/wrap@11.1.14", "", { "dependencies": { "@graphql-tools/delegate": "^12.0.14", "@graphql-tools/schema": "^10.0.29", "@graphql-tools/utils": "^11.0.0", "@whatwg-node/promise-helpers": "^1.3.2", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-ebSVT7apxr+88q3Wy0i4AyRmJ42I0SuMqjPIn1fqW14yCTQRZG8YLuIALG1gKR936+GkfMLOrADh6egJvdlN6Q=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@hono/standard-validator": ["@hono/standard-validator@0.2.2", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "hono": ">=3.9.0" } }, "sha512-mJ7W84Bt/rSvoIl63Ynew+UZOHAzzRAoAXb3JaWuxAkM/Lzg+ZHTCUiz77KOtn2e623WNN8LkD57Dk0szqUrIw=="], - "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], - "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], @@ -424,6 +446,8 @@ "@hyodotdev/openiap-kit": ["@hyodotdev/openiap-kit@workspace:packages/kit"], + "@hyodotdev/openiap-mcp-server": ["@hyodotdev/openiap-mcp-server@workspace:packages/mcp-server"], + "@icons-pack/react-simple-icons": ["@icons-pack/react-simple-icons@13.13.0", "", { "peerDependencies": { "react": "^16.13 || ^17 || ^18 || ^19" } }, "sha512-B5HhQMIpcSH4z8IZ8HFhD59CboHceKYMpPC9kAwGyKntvPdyJJv26DLu4Z1wAjcCLyrJhf11tMhiQGom9Rxb9g=="], "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], @@ -476,43 +500,39 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "@inquirer/ansi": ["@inquirer/ansi@1.0.1", "", {}, "sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw=="], - - "@inquirer/checkbox": ["@inquirer/checkbox@4.3.0", "", { "dependencies": { "@inquirer/ansi": "^1.0.1", "@inquirer/core": "^10.3.0", "@inquirer/figures": "^1.0.14", "@inquirer/type": "^3.0.9", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-5+Q3PKH35YsnoPTh75LucALdAxom6xh5D1oeY561x4cqBuH24ZFVyFREPe14xgnrtmGu3EEt1dIi60wRVSnGCw=="], + "@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="], - "@inquirer/confirm": ["@inquirer/confirm@5.1.19", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/type": "^3.0.9" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-wQNz9cfcxrtEnUyG5PndC8g3gZ7lGDBzmWiXZkX8ot3vfZ+/BLjR8EvyGX4YzQLeVqtAlY/YScZpW7CW8qMoDQ=="], + "@inquirer/checkbox": ["@inquirer/checkbox@4.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/core": "^10.3.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA=="], - "@inquirer/core": ["@inquirer/core@10.3.0", "", { "dependencies": { "@inquirer/ansi": "^1.0.1", "@inquirer/figures": "^1.0.14", "@inquirer/type": "^3.0.9", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Uv2aPPPSK5jeCplQmQ9xadnFx2Zhj9b5Dj7bU6ZeCdDNNY11nhYy4btcSdtDguHqCT2h5oNeQTcUNSGGLA7NTA=="], + "@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="], - "@inquirer/editor": ["@inquirer/editor@4.2.21", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/external-editor": "^1.0.2", "@inquirer/type": "^3.0.9" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-MjtjOGjr0Kh4BciaFShYpZ1s9400idOdvQ5D7u7lE6VztPFoyLcVNE5dXBmEEIQq5zi4B9h2kU+q7AVBxJMAkQ=="], + "@inquirer/core": ["@inquirer/core@10.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A=="], - "@inquirer/expand": ["@inquirer/expand@4.0.21", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/type": "^3.0.9", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-+mScLhIcbPFmuvU3tAGBed78XvYHSvCl6dBiYMlzCLhpr0bzGzd8tfivMMeqND6XZiaZ1tgusbUHJEfc6YzOdA=="], + "@inquirer/editor": ["@inquirer/editor@4.2.23", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/external-editor": "^1.0.3", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ=="], - "@inquirer/external-editor": ["@inquirer/external-editor@1.0.2", "", { "dependencies": { "chardet": "^2.1.0", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ=="], + "@inquirer/expand": ["@inquirer/expand@4.0.23", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew=="], - "@inquirer/figures": ["@inquirer/figures@1.0.14", "", {}, "sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ=="], + "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], - "@inquirer/input": ["@inquirer/input@4.2.5", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/type": "^3.0.9" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-7GoWev7P6s7t0oJbenH0eQ0ThNdDJbEAEtVt9vsrYZ9FulIokvd823yLyhQlWHJPGce1wzP53ttfdCZmonMHyA=="], + "@inquirer/figures": ["@inquirer/figures@1.0.15", "", {}, "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g=="], - "@inquirer/number": ["@inquirer/number@3.0.21", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/type": "^3.0.9" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-5QWs0KGaNMlhbdhOSCFfKsW+/dcAVC2g4wT/z2MCiZM47uLgatC5N20kpkDQf7dHx+XFct/MJvvNGy6aYJn4Pw=="], + "@inquirer/input": ["@inquirer/input@4.3.1", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g=="], - "@inquirer/password": ["@inquirer/password@4.0.21", "", { "dependencies": { "@inquirer/ansi": "^1.0.1", "@inquirer/core": "^10.3.0", "@inquirer/type": "^3.0.9" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-xxeW1V5SbNFNig2pLfetsDb0svWlKuhmr7MPJZMYuDnCTkpVBI+X/doudg4pznc1/U+yYmWFFOi4hNvGgUo7EA=="], + "@inquirer/number": ["@inquirer/number@3.0.23", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg=="], - "@inquirer/prompts": ["@inquirer/prompts@7.9.0", "", { "dependencies": { "@inquirer/checkbox": "^4.3.0", "@inquirer/confirm": "^5.1.19", "@inquirer/editor": "^4.2.21", "@inquirer/expand": "^4.0.21", "@inquirer/input": "^4.2.5", "@inquirer/number": "^3.0.21", "@inquirer/password": "^4.0.21", "@inquirer/rawlist": "^4.1.9", "@inquirer/search": "^3.2.0", "@inquirer/select": "^4.4.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-X7/+dG9SLpSzRkwgG5/xiIzW0oMrV3C0HOa7YHG1WnrLK+vCQHfte4k/T80059YBdei29RBC3s+pSMvPJDU9/A=="], + "@inquirer/password": ["@inquirer/password@4.0.23", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA=="], - "@inquirer/rawlist": ["@inquirer/rawlist@4.1.9", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/type": "^3.0.9", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-AWpxB7MuJrRiSfTKGJ7Y68imYt8P9N3Gaa7ySdkFj1iWjr6WfbGAhdZvw/UnhFXTHITJzxGUI9k8IX7akAEBCg=="], + "@inquirer/prompts": ["@inquirer/prompts@7.10.1", "", { "dependencies": { "@inquirer/checkbox": "^4.3.2", "@inquirer/confirm": "^5.1.21", "@inquirer/editor": "^4.2.23", "@inquirer/expand": "^4.0.23", "@inquirer/input": "^4.3.1", "@inquirer/number": "^3.0.23", "@inquirer/password": "^4.0.23", "@inquirer/rawlist": "^4.1.11", "@inquirer/search": "^3.2.2", "@inquirer/select": "^4.4.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg=="], - "@inquirer/search": ["@inquirer/search@3.2.0", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/figures": "^1.0.14", "@inquirer/type": "^3.0.9", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-a5SzB/qrXafDX1Z4AZW3CsVoiNxcIYCzYP7r9RzrfMpaLpB+yWi5U8BWagZyLmwR0pKbbL5umnGRd0RzGVI8bQ=="], + "@inquirer/rawlist": ["@inquirer/rawlist@4.1.11", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw=="], - "@inquirer/select": ["@inquirer/select@4.4.0", "", { "dependencies": { "@inquirer/ansi": "^1.0.1", "@inquirer/core": "^10.3.0", "@inquirer/figures": "^1.0.14", "@inquirer/type": "^3.0.9", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-kaC3FHsJZvVyIjYBs5Ih8y8Bj4P/QItQWrZW22WJax7zTN+ZPXVGuOM55vzbdCP9zKUiBd9iEJVdesujfF+cAA=="], + "@inquirer/search": ["@inquirer/search@3.2.2", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA=="], - "@inquirer/type": ["@inquirer/type@3.0.9", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w=="], + "@inquirer/select": ["@inquirer/select@4.4.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/core": "^10.3.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w=="], - "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], + "@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="], - "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], - - "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -536,6 +556,8 @@ "@mixpanel/rrweb-utils": ["@mixpanel/rrweb-utils@2.0.0-alpha.18.4", "", {}, "sha512-c3nUbQl19kxHjf8nowFMeXlJw0ZqLesIVBb9t4g1nC4WtaNEPkFotWRdGt5V2cJNQ+aY38/v2uYb8Ren4IcdSQ=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@node-rs/argon2": ["@node-rs/argon2@1.7.0", "", { "optionalDependencies": { "@node-rs/argon2-android-arm-eabi": "1.7.0", "@node-rs/argon2-android-arm64": "1.7.0", "@node-rs/argon2-darwin-arm64": "1.7.0", "@node-rs/argon2-darwin-x64": "1.7.0", "@node-rs/argon2-freebsd-x64": "1.7.0", "@node-rs/argon2-linux-arm-gnueabihf": "1.7.0", "@node-rs/argon2-linux-arm64-gnu": "1.7.0", "@node-rs/argon2-linux-arm64-musl": "1.7.0", "@node-rs/argon2-linux-x64-gnu": "1.7.0", "@node-rs/argon2-linux-x64-musl": "1.7.0", "@node-rs/argon2-wasm32-wasi": "1.7.0", "@node-rs/argon2-win32-arm64-msvc": "1.7.0", "@node-rs/argon2-win32-ia32-msvc": "1.7.0", "@node-rs/argon2-win32-x64-msvc": "1.7.0" } }, "sha512-zfULc+/tmcWcxn+nHkbyY8vP3+MpEqKORbszt4UkpqZgBgDAAIYvuDN/zukfTgdmo6tmJKKVfzigZOPk4LlIog=="], "@node-rs/argon2-android-arm-eabi": ["@node-rs/argon2-android-arm-eabi@1.7.0", "", { "os": "android", "cpu": "arm" }, "sha512-udDqkr5P9E+wYX1SZwAVPdyfYvaF4ry9Tm+R9LkfSHbzWH0uhU6zjIwNRp7m+n4gx691rk+lqqDAIP8RLKwbhg=="], @@ -606,7 +628,7 @@ "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA=="], - "@opentelemetry/core": ["@opentelemetry/core@2.7.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ=="], + "@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w=="], @@ -652,9 +674,9 @@ "@opentelemetry/redis-common": ["@opentelemetry/redis-common@0.38.3", "", {}, "sha512-VCghU1JYs/4gP6Gqf/xro9MEsZ7LrMv2uONVsaESKL38ZOB9BqnI98FfS23wjMnHlpuE+TTaWSoAVNpTwYXzjw=="], - "@opentelemetry/resources": ["@opentelemetry/resources@2.7.0", "", { "dependencies": { "@opentelemetry/core": "2.7.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-K+oi0hNMv94EpZbnW3eyu2X6SGVpD3O5DhG2NIp65Hc7lhAj9brRXTAVzh3wB82+q3ThakEf7Zd7RsFUqcTc7A=="], + "@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], - "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.0", "", { "dependencies": { "@opentelemetry/core": "2.7.0", "@opentelemetry/resources": "2.7.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-Yg9zEXJB50DLVLpsKPk7NmNqlPlS+OvqhJGh0A8oawIOTPOwlm4eXs9BMJV7L79lvEwI+dWtAj+YjTyddV336A=="], + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], @@ -674,9 +696,9 @@ "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], - "@preact/signals-core": ["@preact/signals-core@1.12.1", "", {}, "sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA=="], + "@preact/signals-core": ["@preact/signals-core@1.14.1", "", {}, "sha512-vxPpfXqrwUe9lpjqfYNjAF/0RF/eFGeLgdJzdmIIZjpOnTmGmAB4BjWone562mJGMRP4frU6iZ6ei3PDsu52Ng=="], - "@preact/signals-react": ["@preact/signals-react@3.3.1", "", { "dependencies": { "@preact/signals-core": "^1.12.0", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-VMVDVt2zxtv/uvYBfuaAX1KJPZjpSB23ohbbCAzWAv0J0IXRxyDMSVUirxq1nY6nzeyldAAXJOykAtlVIyQ/jA=="], + "@preact/signals-react": ["@preact/signals-react@3.10.0", "", { "dependencies": { "@preact/signals-core": "^1.14.0", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-Uxu6lidNVr9z27b/6DbCin86ekzHiJDrLXZii82aXSzvyMXYMr7l0Bab1cKbfWdbkxq13e7kS7paix3pjKBTLA=="], "@preact/signals-react-transform": ["@preact/signals-react-transform@0.5.2", "", { "dependencies": { "@babel/helper-module-imports": "^7.22.5", "@babel/helper-plugin-utils": "^7.22.5", "@preact/signals-react": "^3.0.0", "debug": "^4.3.4", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { "@babel/core": "^7.0.0", "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-mUAy3da1t1yatgo9/qBRI47l5HWd+vuEVOGwV6ocZTi3wKB0T/6HejQZxkIiNRp0KIAhFR/Hyzsg6t4TnQ6VEA=="], @@ -770,79 +792,85 @@ "@react-email/render": ["@react-email/render@1.1.2", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw=="], - "@remix-run/router": ["@remix-run/router@1.23.0", "", {}, "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA=="], + "@remix-run/router": ["@remix-run/router@1.23.2", "", {}, "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w=="], "@repeaterjs/repeater": ["@repeaterjs/repeater@3.0.6", "", {}, "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.4", "", { "os": "android", "cpu": "arm" }, "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.2", "", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.4", "", { "os": "android", "cpu": "arm64" }, "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg=="], + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.4", "", { "os": "linux", "cpu": "arm" }, "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.4", "", { "os": "linux", "cpu": "arm" }, "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ=="], + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg=="], + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.4", "", { "os": "linux", "cpu": "x64" }, "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.4", "", { "os": "none", "cpu": "arm64" }, "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ=="], + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.2", "", { "os": "none", "cpu": "arm64" }, "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="], "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], - "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.50.0", "", { "dependencies": { "@sentry/core": "10.50.0" } }, "sha512-42bxyRTxnCmYlWnvz4CxikuQNanw8UNma2WJrtxJ0f1MAJV2GhQGSHDLnA+lvFlmiz6qct3pfen/NXGyOTegTA=="], + "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.51.0", "", { "dependencies": { "@sentry/core": "10.51.0" } }, "sha512-lNKBS4P7RUvf1niojXQWe9bU3gnBUCbST4Dj0pSiyat1N96cXVyHkeE+uGxowD0RrVWhs+kGHiVX3FcmRWF6sA=="], - "@sentry-internal/feedback": ["@sentry-internal/feedback@10.50.0", "", { "dependencies": { "@sentry/core": "10.50.0" } }, "sha512-0k9XZF0wn86f77mIO2U3gNNyDZooy139CnEanRzHinrN106vVzvBZ6TUEQoHtoO1fqQxr+nWWVrqV/PXUqk47w=="], + "@sentry-internal/feedback": ["@sentry-internal/feedback@10.51.0", "", { "dependencies": { "@sentry/core": "10.51.0" } }, "sha512-bCM95bcpphx28e6aU0bwRLxOgwosYsdNzezM1sM0pVOkb0TB3hDFRamramVDK+/Hp1o8qmRxS4c5w/A7YBZGkA=="], - "@sentry-internal/replay": ["@sentry-internal/replay@10.50.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.50.0", "@sentry/core": "10.50.0" } }, "sha512-51FYNfnvVLAWw1rrEWPFfwHuMRb9mkVCFGA4J9/un7SpeGBsQDziGB0Di4fsCxI7+EdSBpfLHPF0csKtCCw0oQ=="], + "@sentry-internal/replay": ["@sentry-internal/replay@10.51.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.51.0", "@sentry/core": "10.51.0" } }, "sha512-jCpI5HXSwK6ZT2HX70+mDRciAocHzSiDk4DTgvzV69Wvd+Ei5WLgE+d39eaEPsm8lUC0Ydntb5sJIB6uG9D4bw=="], - "@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.50.0", "", { "dependencies": { "@sentry-internal/replay": "10.50.0", "@sentry/core": "10.50.0" } }, "sha512-jx6RKBmcJSWdI92qDGS/sBv1w+7Cww879Z/moX7bw7ipHa/Ts3iDcB3rgZwvhmi17U+mvYsbJeL2DXkPo3TjPw=="], + "@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.51.0", "", { "dependencies": { "@sentry-internal/replay": "10.51.0", "@sentry/core": "10.51.0" } }, "sha512-8PW1Pp+Yl3lPwYqhBCr5SgkuhDanu9ZLzUqD2bPKL/ElqbM2eDVIWxq4z4ZzePrmZa6IcCjTv6sVQJ7Z4dLyLA=="], - "@sentry/browser": ["@sentry/browser@10.50.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.50.0", "@sentry-internal/feedback": "10.50.0", "@sentry-internal/replay": "10.50.0", "@sentry-internal/replay-canvas": "10.50.0", "@sentry/core": "10.50.0" } }, "sha512-1f6rAvET6myiTaSeYqvaaBwvq1LfxqWjAPIoAW/NVC9bPMkeEcuvgDajHrnZMrBeWoJ81NMyoLkyX+iOc7MoFA=="], + "@sentry/browser": ["@sentry/browser@10.51.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.51.0", "@sentry-internal/feedback": "10.51.0", "@sentry-internal/replay": "10.51.0", "@sentry-internal/replay-canvas": "10.51.0", "@sentry/core": "10.51.0" } }, "sha512-Zdc0sKfenxUtW/OGhtJ7xHFN44bXR7YqxJ1zBDzlZfW0nTbeTTUZBq9z5NUw6qdS0Vs/i3V4qzAKTbRKWfqSEA=="], - "@sentry/bun": ["@sentry/bun@10.50.0", "", { "dependencies": { "@sentry/core": "10.50.0", "@sentry/node": "10.50.0" } }, "sha512-TAGojqOd5ItSBSPMFdJtUDfSGgYaZ7odZI64WmTwG4zYfRFYs3BxwxalU2ljs7kxIVqFoJZoxlEeXbR/SBhgxA=="], + "@sentry/bun": ["@sentry/bun@10.51.0", "", { "dependencies": { "@sentry/core": "10.51.0", "@sentry/node": "10.51.0" } }, "sha512-LOOrMSKHTQ8kx+o75jelIHXUpFlIwAR5UXBNluV20NxyIsKEav3QsxP0J1Oe02hNVuo3yIlK55M3G9Zc4qEuAw=="], - "@sentry/core": ["@sentry/core@10.50.0", "", {}, "sha512-J4A+vzUO3adl0TkFCjaN1+4miamrjHiEIYuLHiuu1lmAjq5WIVw32ObvAh4yMwNtxyaEMosTrrh5M6f12XSJFg=="], + "@sentry/core": ["@sentry/core@10.51.0", "", {}, "sha512-Y45V/YXvVLEXmOdkbD1oG1gkRWFi9guCEGg3PlIlIpRjAbZUrvLGgjRJIc1E7XpSzmOnWbs5BbUxMv4PDaPj2w=="], - "@sentry/node": ["@sentry/node@10.50.0", "", { "dependencies": { "@fastify/otel": "0.18.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/instrumentation-amqplib": "0.61.0", "@opentelemetry/instrumentation-connect": "0.57.0", "@opentelemetry/instrumentation-dataloader": "0.31.0", "@opentelemetry/instrumentation-fs": "0.33.0", "@opentelemetry/instrumentation-generic-pool": "0.57.0", "@opentelemetry/instrumentation-graphql": "0.62.0", "@opentelemetry/instrumentation-hapi": "0.60.0", "@opentelemetry/instrumentation-http": "0.214.0", "@opentelemetry/instrumentation-ioredis": "0.62.0", "@opentelemetry/instrumentation-kafkajs": "0.23.0", "@opentelemetry/instrumentation-knex": "0.58.0", "@opentelemetry/instrumentation-koa": "0.62.0", "@opentelemetry/instrumentation-lru-memoizer": "0.58.0", "@opentelemetry/instrumentation-mongodb": "0.67.0", "@opentelemetry/instrumentation-mongoose": "0.60.0", "@opentelemetry/instrumentation-mysql": "0.60.0", "@opentelemetry/instrumentation-mysql2": "0.60.0", "@opentelemetry/instrumentation-pg": "0.66.0", "@opentelemetry/instrumentation-redis": "0.62.0", "@opentelemetry/instrumentation-tedious": "0.33.0", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@prisma/instrumentation": "7.6.0", "@sentry/core": "10.50.0", "@sentry/node-core": "10.50.0", "@sentry/opentelemetry": "10.50.0", "import-in-the-middle": "^3.0.0" } }, "sha512-TvwzFQu8MGKzMQ2/tqxcNzFA8UG2kKTB+GDmA4uOzx3+GT849YZRRSJzEXCmYhk1teVd2fbmgqyYY2nyLF5a+Q=="], + "@sentry/node": ["@sentry/node@10.51.0", "", { "dependencies": { "@fastify/otel": "0.18.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/instrumentation-amqplib": "0.61.0", "@opentelemetry/instrumentation-connect": "0.57.0", "@opentelemetry/instrumentation-dataloader": "0.31.0", "@opentelemetry/instrumentation-fs": "0.33.0", "@opentelemetry/instrumentation-generic-pool": "0.57.0", "@opentelemetry/instrumentation-graphql": "0.62.0", "@opentelemetry/instrumentation-hapi": "0.60.0", "@opentelemetry/instrumentation-http": "0.214.0", "@opentelemetry/instrumentation-ioredis": "0.62.0", "@opentelemetry/instrumentation-kafkajs": "0.23.0", "@opentelemetry/instrumentation-knex": "0.58.0", "@opentelemetry/instrumentation-koa": "0.62.0", "@opentelemetry/instrumentation-lru-memoizer": "0.58.0", "@opentelemetry/instrumentation-mongodb": "0.67.0", "@opentelemetry/instrumentation-mongoose": "0.60.0", "@opentelemetry/instrumentation-mysql": "0.60.0", "@opentelemetry/instrumentation-mysql2": "0.60.0", "@opentelemetry/instrumentation-pg": "0.66.0", "@opentelemetry/instrumentation-redis": "0.62.0", "@opentelemetry/instrumentation-tedious": "0.33.0", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@prisma/instrumentation": "7.6.0", "@sentry/core": "10.51.0", "@sentry/node-core": "10.51.0", "@sentry/opentelemetry": "10.51.0", "import-in-the-middle": "^3.0.0" } }, "sha512-2yZLRZwS1dKG8/4eOTpGSo/gO/EgmT9aPj6lAzUkRa7bZCTTdW4BraaHU0leX5T94909Qfhbr3W5AVTfDOCKiQ=="], - "@sentry/node-core": ["@sentry/node-core@10.50.0", "", { "dependencies": { "@sentry/core": "10.50.0", "@sentry/opentelemetry": "10.50.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base", "@opentelemetry/semantic-conventions"] }, "sha512-Eb1BYf4Lc7ZYmdX3acKP6SgyGikrBA370gbGHaWI5jRu7G7vig8sIu1ghPmY5AlvqBPOetado7GniXr6fAXbTw=="], + "@sentry/node-core": ["@sentry/node-core@10.51.0", "", { "dependencies": { "@sentry/core": "10.51.0", "@sentry/opentelemetry": "10.51.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base", "@opentelemetry/semantic-conventions"] }, "sha512-VP9DMEzBEuauABrfDHYz/pRYa74M09uRJLz0ls3yel3sKhYHMyCB29ZxbKcciUhD4d33dwgi8DbaPZV2H/wnfQ=="], - "@sentry/opentelemetry": ["@sentry/opentelemetry@10.50.0", "", { "dependencies": { "@sentry/core": "10.50.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" } }, "sha512-axn3pgDPveGdaMUC0abMCmFN7ux2pA5ebPufCef4lMIsyg7BBQvaEJ+vE19wjstMaBCAJGsdZlL3eeP2rtgRMw=="], + "@sentry/opentelemetry": ["@sentry/opentelemetry@10.51.0", "", { "dependencies": { "@sentry/core": "10.51.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" } }, "sha512-Qc7AlCE4uhB+SvHLqah4RgR1WdY7wmmr/hx9g/prDP9R1ocshmUEMrZK9qjuwaklW7/fmkFCXI8ETxo5L1bHIA=="], - "@sentry/react": ["@sentry/react@10.50.0", "", { "dependencies": { "@sentry/browser": "10.50.0", "@sentry/core": "10.50.0" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-MZHYjEZAtFIa4zPrWS4oXlo+gMppRvfETqUqF920Sj2jN2U7WjboU03lDmjfDqEcH7QiwjQyl13jHd2nwAyrrw=="], + "@sentry/react": ["@sentry/react@10.51.0", "", { "dependencies": { "@sentry/browser": "10.51.0", "@sentry/core": "10.51.0" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-RRHHqjNvjji6ebIqdlAr453AkST8Vm4cxdu1vWm772IgbzTO7Jx46Cj6Bt2/GjMyH0YLE5euDaAOQhFMmpvAOw=="], "@standard-community/standard-json": ["@standard-community/standard-json@0.3.5", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "@valibot/to-json-schema": "^1.3.0", "arktype": "^2.1.20", "effect": "^3.16.8", "quansync": "^0.2.11", "sury": "^10.0.0", "typebox": "^1.0.17", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.5" }, "optionalPeers": ["@valibot/to-json-schema", "arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-to-json-schema"] }, "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA=="], @@ -888,9 +916,7 @@ "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], - "@theguild/federation-composition": ["@theguild/federation-composition@0.20.1", "", { "dependencies": { "constant-case": "^3.0.4", "debug": "4.4.1", "json5": "^2.2.3", "lodash.sortby": "^4.7.0" }, "peerDependencies": { "graphql": "^16.0.0" } }, "sha512-lwYYKCeHmstOtbMtzxC0BQKWsUPYbEVRVdJ3EqR4jSpcF4gvNf3MOJv6yuvq6QsKqgYZURKRBszmg7VEDoi5Aw=="], - - "@tsconfig/node10": ["@tsconfig/node10@1.0.11", "", {}, "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw=="], + "@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="], "@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="], @@ -952,7 +978,7 @@ "@types/pg-pool": ["@types/pg-pool@2.0.7", "", { "dependencies": { "@types/pg": "*" } }, "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng=="], - "@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="], + "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -964,25 +990,25 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/type-utils": "8.46.1", "@typescript-eslint/utils": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/type-utils": "8.59.1", "@typescript-eslint/utils": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.46.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", "@typescript-eslint/typescript-estree": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.46.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.46.1", "@typescript-eslint/types": "^8.46.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.1", "@typescript-eslint/types": "^8.59.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1" } }, "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.1", "", { "dependencies": { "@typescript-eslint/types": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1" } }, "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.46.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "@typescript-eslint/typescript-estree": "8.46.1", "@typescript-eslint/utils": "8.46.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.1", "", { "dependencies": { "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/utils": "8.59.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.46.1", "", {}, "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.59.1", "", {}, "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.46.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.46.1", "@typescript-eslint/tsconfig-utils": "8.46.1", "@typescript-eslint/types": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.1", "@typescript-eslint/tsconfig-utils": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.46.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", "@typescript-eslint/typescript-estree": "8.46.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.1", "", { "dependencies": { "@typescript-eslint/types": "8.59.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -1006,27 +1032,31 @@ "@whatwg-node/disposablestack": ["@whatwg-node/disposablestack@0.0.6", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.6.3" } }, "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw=="], - "@whatwg-node/fetch": ["@whatwg-node/fetch@0.10.11", "", { "dependencies": { "@whatwg-node/node-fetch": "^0.8.0", "urlpattern-polyfill": "^10.0.0" } }, "sha512-eR8SYtf9Nem1Tnl0IWrY33qJ5wCtIWlt3Fs3c6V4aAaTFLtkEQErXu3SSZg/XCHrj9hXSJ8/8t+CdMk5Qec/ZA=="], + "@whatwg-node/fetch": ["@whatwg-node/fetch@0.10.13", "", { "dependencies": { "@whatwg-node/node-fetch": "^0.8.3", "urlpattern-polyfill": "^10.0.0" } }, "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q=="], - "@whatwg-node/node-fetch": ["@whatwg-node/node-fetch@0.8.1", "", { "dependencies": { "@fastify/busboy": "^3.1.1", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/promise-helpers": "^1.3.2", "tslib": "^2.6.3" } }, "sha512-cQmQEo7IsI0EPX9VrwygXVzrVlX43Jb7/DBZSmpnC7xH4xkyOnn/HykHpTaQk7TUs7zh59A5uTGqx3p2Ouzffw=="], + "@whatwg-node/node-fetch": ["@whatwg-node/node-fetch@0.8.5", "", { "dependencies": { "@fastify/busboy": "^3.1.1", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/promise-helpers": "^1.3.2", "tslib": "^2.6.3" } }, "sha512-4xzCl/zphPqlp9tASLVeUhB5+WJHbuWGYpfoC2q1qh5dw0AqZBW7L27V5roxYWijPxj4sspRAAoOH3d2ztaHUQ=="], "@whatwg-node/promise-helpers": ["@whatwg-node/promise-helpers@1.3.2", "", { "dependencies": { "tslib": "^2.6.3" } }, "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA=="], "@xstate/fsm": ["@xstate/fsm@1.6.5", "", {}, "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw=="], - "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="], + "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "ansi-escapes": ["ansi-escapes@7.1.1", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -1046,8 +1076,6 @@ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], - "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], @@ -1072,26 +1100,28 @@ "base64url": ["base64url@3.0.1", "", {}, "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.22", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-6qruVrb5rse6WylFkU0FhBKKGuecWseqdpQfhkawn6ztyk2QlfwSRjsDxMCLJrkfmfN21qvhl9ABgaMeRkuwww=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.24", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA=="], "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], - "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], - "bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="], - "bubble-stream-error": ["bubble-stream-error@1.0.0", "", { "dependencies": { "once": "^1.3.3", "sliced": "^1.0.1" } }, "sha512-Rqf0ly5H4HGt+ki/n3m7GxoR2uIGtNqezPlOLX8Vuo13j5/tfPuVvAr84eoGF7sYm6lKdbGnT/3q8qmzuT5Y9w=="], "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -1124,13 +1154,13 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - "chardet": ["chardet@2.1.0", "", {}, "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA=="], + "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], - "cli-truncate": ["cli-truncate@5.1.0", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-7JDGG+4Zp0CsknDCedl0DYdaeOhc46QNpXi3NLQblkZpXXgA6LncLDUUyvrjSvZeF3VRQa+KiMGomazQrC1V8g=="], + "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], @@ -1152,7 +1182,7 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "commander": ["commander@14.0.1", "", {}, "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="], @@ -1162,17 +1192,23 @@ "constant-case": ["constant-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3", "upper-case": "^2.0.2" } }, "sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "convex": ["convex@1.36.1", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "@clerk/react": "^6.4.3", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "@clerk/react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-NVnwNqU+h8jyPuS0Itvj4MPH9c2yF+tA/RNoSDpCqiLhmYD4+kZxm0dDkVM0QDzz66wem9NqheBb9YQGsHwzBQ=="], + "convex": ["convex@1.37.0", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "@clerk/react": "^6.4.3", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "@clerk/react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-xGSx5edIsXCEex3OU2U2N0oyB/cOa9qGwKiImF9yOWqjqZgOkx39idtpdlwNBTBSt4S30oAvs4yeXY5xxPIX3A=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - "create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="], - "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], + "create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="], "cross-inspect": ["cross-inspect@1.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A=="], @@ -1216,6 +1252,8 @@ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dependency-graph": ["dependency-graph@1.0.0", "", {}, "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], @@ -1226,7 +1264,7 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "diff": ["diff@4.0.2", "", {}, "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="], + "diff": ["diff@4.0.4", "", {}, "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ=="], "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], @@ -1244,18 +1282,18 @@ "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], - "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], - "electron-to-chromium": ["electron-to-chromium@1.5.344", "", {}, "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.348", "", {}, "sha512-QC2X59nRlycQQMc4ZXjSVBX+tSgJfgRtcrYHbIZLgOV2dCvefoQGegLR7lLXKgpPpSuVmJU19LMzGrSa2C7k3Q=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="], "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], @@ -1284,13 +1322,15 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@9.37.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.4.0", "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.37.0", "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig=="], + "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], - "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.24", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w=="], + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="], "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], @@ -1298,7 +1338,7 @@ "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], - "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], @@ -1310,10 +1350,20 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.4.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -1324,13 +1374,9 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], - - "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], - - "fbjs": ["fbjs@3.0.5", "", { "dependencies": { "cross-fetch": "^3.1.5", "fbjs-css-vars": "^1.0.0", "loose-envify": "^1.0.0", "object-assign": "^4.1.0", "promise": "^7.1.1", "setimmediate": "^1.0.5", "ua-parser-js": "^1.0.35" } }, "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fbjs-css-vars": ["fbjs-css-vars@1.0.2", "", {}, "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ=="], + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -1342,6 +1388,8 @@ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], @@ -1356,10 +1404,14 @@ "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + "forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="], "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fs-monkey": ["fs-monkey@1.1.0", "", {}, "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -1380,7 +1432,7 @@ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], @@ -1392,7 +1444,7 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="], + "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], @@ -1410,17 +1462,15 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], - - "graphql": ["graphql@16.11.0", "", {}, "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw=="], + "graphql": ["graphql@16.13.2", "", {}, "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="], - "graphql-config": ["graphql-config@5.1.5", "", { "dependencies": { "@graphql-tools/graphql-file-loader": "^8.0.0", "@graphql-tools/json-file-loader": "^8.0.0", "@graphql-tools/load": "^8.1.0", "@graphql-tools/merge": "^9.0.0", "@graphql-tools/url-loader": "^8.0.0", "@graphql-tools/utils": "^10.0.0", "cosmiconfig": "^8.1.0", "jiti": "^2.0.0", "minimatch": "^9.0.5", "string-env-interpolation": "^1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "cosmiconfig-toml-loader": "^1.0.0", "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["cosmiconfig-toml-loader"] }, "sha512-mG2LL1HccpU8qg5ajLROgdsBzx/o2M6kgI3uAmoaXiSH9PCUbtIyLomLqUtCFaAeG2YCFsl0M5cfQ9rKmDoMVA=="], + "graphql-config": ["graphql-config@5.1.6", "", { "dependencies": { "@graphql-tools/graphql-file-loader": "^8.0.0", "@graphql-tools/json-file-loader": "^8.0.0", "@graphql-tools/load": "^8.1.0", "@graphql-tools/merge": "^9.0.0", "@graphql-tools/url-loader": "^9.0.0", "@graphql-tools/utils": "^11.0.0", "cosmiconfig": "^8.1.0", "jiti": "^2.0.0", "minimatch": "^10.0.0", "string-env-interpolation": "^1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "cosmiconfig-toml-loader": "^1.0.0", "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["cosmiconfig-toml-loader"] }, "sha512-fCkYnm4Kdq3un0YIM4BCZHVR5xl0UeLP6syxxO7KAstdY7QVyVvTHP0kRPDYEP1v08uwtJVgis5sj3IOTLOniQ=="], "graphql-tag": ["graphql-tag@2.12.6", "", { "dependencies": { "tslib": "^2.1.0" }, "peerDependencies": { "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg=="], - "graphql-ws": ["graphql-ws@6.0.6", "", { "peerDependencies": { "@fastify/websocket": "^10 || ^11", "crossws": "~0.3", "graphql": "^15.10.1 || ^16", "uWebSockets.js": "^20", "ws": "^8" }, "optionalPeers": ["@fastify/websocket", "crossws", "uWebSockets.js", "ws"] }, "sha512-zgfER9s+ftkGKUZgc0xbx8T7/HMO4AV5/YuYiFc+AtgcO5T0v8AxYYNQ+ltzuzDZgNkYJaFspm5MMYLjQzrkmw=="], + "graphql-ws": ["graphql-ws@6.0.8", "", { "peerDependencies": { "@fastify/websocket": "^10 || ^11", "crossws": "~0.3", "graphql": "^15.10.1 || ^16", "ws": "^8" }, "optionalPeers": ["@fastify/websocket", "crossws", "ws"] }, "sha512-m3EOaNsUBXwAnkBWbzPfe0Nq8pXUfxsWnolC54sru3FzHvhTZL0Ouf/BoQsaGAXqM+YPerXOJ47BUnmgmoupCw=="], - "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], + "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], @@ -1442,7 +1492,7 @@ "header-case": ["header-case@2.0.4", "", { "dependencies": { "capital-case": "^1.0.4", "tslib": "^2.0.3" } }, "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q=="], - "hono": ["hono@4.12.15", "", {}, "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg=="], + "hono": ["hono@4.12.16", "", {}, "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg=="], "hono-openapi": ["hono-openapi@1.3.0", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-xDvCWpWEIv0weEmnl3EjRQzqbHIO8LnfzMuYOCmbuyE5aes6aXxLg4vM3ybnoZD5TiTUkA6PuRQPJs3R7WRBig=="], @@ -1456,15 +1506,17 @@ "htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], - "iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "immutable": ["immutable@3.7.6", "", {}, "sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw=="], + "immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -1476,12 +1528,18 @@ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-absolute": ["is-absolute@1.0.0", "", { "dependencies": { "is-relative": "^1.0.0", "is-windows": "^1.0.1" } }, "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -1540,6 +1598,8 @@ "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], "is-relative": ["is-relative@1.0.0", "", { "dependencies": { "is-unc-path": "^1.0.0" } }, "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA=="], @@ -1574,7 +1634,9 @@ "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], - "jackspeak": ["jackspeak@4.1.1", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" } }, "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ=="], + "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], + + "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -1582,9 +1644,9 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "jsdom": ["jsdom@29.1.0", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg=="], + "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -1600,6 +1662,8 @@ "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "json-to-pretty-yaml": ["json-to-pretty-yaml@1.2.2", "", { "dependencies": { "remedial": "^1.0.7", "remove-trailing-spaces": "^1.0.6" } }, "sha512-rvm6hunfCcqegwYaG5T4yKJWxc9FXFgBVrcTZ4XfSVRwa5HA/Xs+vB/Eo9treYYHCeNM0nrSUr82V/M31Urc7A=="], @@ -1650,16 +1714,14 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "lint-staged": ["lint-staged@16.2.4", "", { "dependencies": { "commander": "^14.0.1", "listr2": "^9.0.4", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-Pkyr/wd90oAyXk98i/2KwfkIhoYQUMtss769FIT9hFM5ogYZwrk+GRE46yKXSg2ZGhcJ1p38Gf5gmI5Ohjg2yg=="], + "lint-staged": ["lint-staged@16.4.0", "", { "dependencies": { "commander": "^14.0.3", "listr2": "^9.0.5", "picomatch": "^4.0.3", "string-argv": "^0.3.2", "tinyexec": "^1.0.4", "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw=="], - "listr2": ["listr2@9.0.4", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-1wd/kpAdKRLwv7/3OKC8zZ5U8e/fajCfWMxacUvB79S5nLrYGPtUI/8chMQhn3LQjsRVErTb9i1ECAwW0ZIHnQ=="], + "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], "load-json-file": ["load-json-file@4.0.0", "", { "dependencies": { "graceful-fs": "^4.1.2", "parse-json": "^4.0.0", "pify": "^3.0.0", "strip-bom": "^3.0.0" } }, "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], - "lodash._baseflatten": ["lodash._baseflatten@3.1.4", "", { "dependencies": { "lodash.isarguments": "^3.0.0", "lodash.isarray": "^3.0.0" } }, "sha512-fESngZd+X4k+GbTxdMutf8ohQa0s3sJEHIcwtu4/LsIQ2JTDzdRxDCMQjW+ezzwRitLmHnacVVmosCbxifefbw=="], "lodash._basefor": ["lodash._basefor@3.0.3", "", {}, "sha512-6bc3b8grkpMgDcVJv9JYZAk/mHgcqMljzm7OsbmcE2FGUMmmLQTPHlh/dFqR8LA0GQ7z4K67JSotVKu5058v1A=="], @@ -1760,12 +1822,16 @@ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "memfs": ["memfs@3.5.3", "", { "dependencies": { "fs-monkey": "^1.0.4" } }, "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw=="], "memfs-browser": ["memfs-browser@3.5.10302", "", { "dependencies": { "memfs": "3.5.3" } }, "sha512-JJTc/nh3ig05O0gBBGZjTCPOyydaTxNF0uHYBrcc1gHNnO+KIHIvo0Y1FKCJsaei6FCl8C6xfQomXqu+cuzkIw=="], "memorystream": ["memorystream@0.3.1", "", {}, "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw=="], + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "meros": ["meros@1.3.2", "", { "peerDependencies": { "@types/node": ">=13" }, "optionalPeers": ["@types/node"] }, "sha512-Q3mobPbvEx7XbwhnC1J1r60+5H6EZyNccdzSz0eGexJRwouUtTZxPVRGdqKtxlpD84ScK4+tIGldkqDtCKdI0A=="], @@ -1828,19 +1894,19 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], - "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], @@ -1854,12 +1920,12 @@ "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], - "nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="], - - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], "nice-try": ["nice-try@1.0.5", "", {}, "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="], @@ -1870,8 +1936,6 @@ "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], - "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], - "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], "normalize-package-data": ["normalize-package-data@2.5.0", "", { "dependencies": { "hosted-git-info": "^2.1.4", "resolve": "^1.10.0", "semver": "2 || 3 || 4 || 5", "validate-npm-package-license": "^3.0.1" } }, "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA=="], @@ -1880,8 +1944,6 @@ "npm-run-all": ["npm-run-all@4.1.5", "", { "dependencies": { "ansi-styles": "^3.2.1", "chalk": "^2.4.1", "cross-spawn": "^6.0.5", "memorystream": "^0.3.1", "minimatch": "^3.0.4", "pidtree": "^0.3.0", "read-pkg": "^3.0.0", "shell-quote": "^1.6.1", "string.prototype.padend": "^3.0.0" }, "bin": { "run-p": "bin/run-p/index.js", "run-s": "bin/run-s/index.js", "npm-run-all": "bin/npm-run-all/index.js" } }, "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ=="], - "nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="], - "oauth4webapi": ["oauth4webapi@3.8.6", "", {}, "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -1894,6 +1956,8 @@ "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], @@ -1926,6 +1990,8 @@ "parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "pascal-case": ["pascal-case@3.1.2", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g=="], "path-case": ["path-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg=="], @@ -1940,7 +2006,7 @@ "path-root-regex": ["path-root-regex@0.1.2", "", {}, "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ=="], - "path-scurry": ["path-scurry@2.0.0", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], @@ -1960,17 +2026,19 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], + "pidtree": ["pidtree@0.3.1", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA=="], "pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], - "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "postcss": ["postcss@8.5.13", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag=="], "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], @@ -1988,7 +2056,7 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], @@ -1996,10 +2064,10 @@ "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], - "promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="], - "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], @@ -2008,6 +2076,10 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], "react-compiler-runtime": ["react-compiler-runtime@19.1.0-rc.1-rc-af1b7da-20250421", "", { "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental" } }, "sha512-Til/juI+Zfq+eYpGYn9lFxqW5RyJDs3ThOxmg0757aMrPpfx/Zb0SnGMVJhF3vw+bEQjJiD+xPFD3+kE0WbyeA=="], @@ -2018,7 +2090,7 @@ "react-helmet-async": ["react-helmet-async@2.0.5", "", { "dependencies": { "invariant": "^2.2.4", "react-fast-compare": "^3.2.2", "shallowequal": "^1.1.0" }, "peerDependencies": { "react": "^16.6.0 || ^17.0.0 || ^18.0.0" } }, "sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg=="], - "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], + "react-icons": ["react-icons@5.6.0", "", { "peerDependencies": { "react": "*" } }, "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA=="], "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], @@ -2028,11 +2100,11 @@ "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], - "react-router": ["react-router@6.30.1", "", { "dependencies": { "@remix-run/router": "1.23.0" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ=="], + "react-router": ["react-router@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw=="], - "react-router-dom": ["react-router-dom@6.30.1", "", { "dependencies": { "@remix-run/router": "1.23.0", "react-router": "6.30.1" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw=="], + "react-router-dom": ["react-router-dom@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2", "react-router": "6.30.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag=="], - "react-toastify": ["react-toastify@11.0.5", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA=="], + "react-toastify": ["react-toastify@11.1.0", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg=="], "read-pkg": ["read-pkg@3.0.0", "", { "dependencies": { "load-json-file": "^4.0.0", "normalize-package-data": "^2.3.2", "path-type": "^3.0.0" } }, "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA=="], @@ -2042,8 +2114,6 @@ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], - "relay-runtime": ["relay-runtime@12.0.0", "", { "dependencies": { "@babel/runtime": "^7.0.0", "fbjs": "^3.0.0", "invariant": "^2.2.4" } }, "sha512-QU6JKr1tMsry22DXNy9Whsq5rmvwr3LSZiiWV/9+DFpuTWvp+WFhobWMc8TC4OjKFfNhEZy7mOiqUAn5atQtug=="], - "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], @@ -2076,7 +2146,9 @@ "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], - "rollup": ["rollup@4.52.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.4", "@rollup/rollup-android-arm64": "4.52.4", "@rollup/rollup-darwin-arm64": "4.52.4", "@rollup/rollup-darwin-x64": "4.52.4", "@rollup/rollup-freebsd-arm64": "4.52.4", "@rollup/rollup-freebsd-x64": "4.52.4", "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", "@rollup/rollup-linux-arm-musleabihf": "4.52.4", "@rollup/rollup-linux-arm64-gnu": "4.52.4", "@rollup/rollup-linux-arm64-musl": "4.52.4", "@rollup/rollup-linux-loong64-gnu": "4.52.4", "@rollup/rollup-linux-ppc64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-musl": "4.52.4", "@rollup/rollup-linux-s390x-gnu": "4.52.4", "@rollup/rollup-linux-x64-gnu": "4.52.4", "@rollup/rollup-linux-x64-musl": "4.52.4", "@rollup/rollup-openharmony-arm64": "4.52.4", "@rollup/rollup-win32-arm64-msvc": "4.52.4", "@rollup/rollup-win32-ia32-msvc": "4.52.4", "@rollup/rollup-win32-x64-gnu": "4.52.4", "@rollup/rollup-win32-x64-msvc": "4.52.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ=="], + "rollup": ["rollup@4.60.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.2", "@rollup/rollup-android-arm64": "4.60.2", "@rollup/rollup-darwin-arm64": "4.60.2", "@rollup/rollup-darwin-x64": "4.60.2", "@rollup/rollup-freebsd-arm64": "4.60.2", "@rollup/rollup-freebsd-x64": "4.60.2", "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", "@rollup/rollup-linux-arm-musleabihf": "4.60.2", "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@rollup/rollup-linux-arm64-musl": "4.60.2", "@rollup/rollup-linux-loong64-gnu": "4.60.2", "@rollup/rollup-linux-loong64-musl": "4.60.2", "@rollup/rollup-linux-ppc64-gnu": "4.60.2", "@rollup/rollup-linux-ppc64-musl": "4.60.2", "@rollup/rollup-linux-riscv64-gnu": "4.60.2", "@rollup/rollup-linux-riscv64-musl": "4.60.2", "@rollup/rollup-linux-s390x-gnu": "4.60.2", "@rollup/rollup-linux-x64-gnu": "4.60.2", "@rollup/rollup-linux-x64-musl": "4.60.2", "@rollup/rollup-openbsd-x64": "4.60.2", "@rollup/rollup-openharmony-arm64": "4.60.2", "@rollup/rollup-win32-arm64-msvc": "4.60.2", "@rollup/rollup-win32-ia32-msvc": "4.60.2", "@rollup/rollup-win32-x64-gnu": "4.60.2", "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -2098,10 +2170,14 @@ "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], - "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "sentence-case": ["sentence-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3", "upper-case-first": "^2.0.2" } }, "sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="], "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], @@ -2112,7 +2188,7 @@ "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], - "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="], @@ -2138,15 +2214,13 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "signedsource": ["signedsource@1.0.0", "", {}, "sha512-6+eerH9fEnNmi/hyM1DXcRK3pWdoMQtlkQ+ns0ntzunjKqp5i3sKCc80ym8Fib3iaYhdJUOPdhlJWj1tvge2Ww=="], - "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], - "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], "sliced": ["sliced@1.0.1", "", {}, "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA=="], @@ -2172,6 +2246,8 @@ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -2184,8 +2260,6 @@ "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "string.prototype.padend": ["string.prototype.padend@3.1.6", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q=="], "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], @@ -2196,9 +2270,7 @@ "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], - "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - - "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], @@ -2220,7 +2292,7 @@ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], - "sync-fetch": ["sync-fetch@0.6.0-2", "", { "dependencies": { "node-fetch": "^3.3.2", "timeout-signal": "^2.0.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-c7AfkZ9udatCuAy9RSfiGPpeOKKUAUK5e1cXadLOGUjasdxqYqAK0jTNkM/FSEyJ3a5Ra27j/tw/PS0qLmaF/A=="], + "sync-fetch": ["sync-fetch@0.6.0", "", { "dependencies": { "node-fetch": "^3.3.2", "timeout-signal": "^2.0.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-IELLEvzHuCfc1uTsshPK58ViSdNqXxlml1U+fmwJIKLYKOr/rAtBrorE2RYm5IHaMpDNlmC0fr1LAvdXvyheEQ=="], "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], @@ -2236,7 +2308,7 @@ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], + "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], @@ -2250,6 +2322,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], @@ -2260,16 +2334,18 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], - "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], "ts-log": ["ts-log@2.2.7", "", {}, "sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg=="], "ts-node": ["ts-node@10.9.2", "", { "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", "@tsconfig/node16": "^1.0.2", "acorn": "^8.4.1", "acorn-walk": "^8.1.1", "arg": "^4.1.0", "create-require": "^1.1.0", "diff": "^4.0.1", "make-error": "^1.1.1", "v8-compile-cache-lib": "^3.0.1", "yn": "3.1.1" }, "peerDependencies": { "@swc/core": ">=1.2.50", "@swc/wasm": ">=1.2.50", "@types/node": "*", "typescript": ">=2.7" }, "optionalPeers": ["@swc/core", "@swc/wasm"], "bin": { "ts-node": "dist/bin.js", "ts-script": "dist/bin-script-deprecated.js", "ts-node-cwd": "dist/bin-cwd.js", "ts-node-esm": "dist/bin-esm.js", "ts-node-script": "dist/bin-script.js", "ts-node-transpile-only": "dist/bin-transpile.js" } }, "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ=="], - "tslib": ["tslib@2.6.3", "", {}, "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], @@ -2280,9 +2356,7 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "typescript-eslint": ["typescript-eslint@8.46.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.46.1", "@typescript-eslint/parser": "8.46.1", "@typescript-eslint/typescript-estree": "8.46.1", "@typescript-eslint/utils": "8.46.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA=="], - - "ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="], + "typescript-eslint": ["typescript-eslint@8.59.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.1", "@typescript-eslint/parser": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/utils": "8.59.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ=="], "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], @@ -2308,6 +2382,8 @@ "unixify": ["unixify@1.0.0", "", { "dependencies": { "normalize-path": "^2.1.1" } }, "sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "upper-case": ["upper-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg=="], @@ -2328,11 +2404,13 @@ "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "vite": ["vite@5.4.20", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g=="], + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], "vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="], @@ -2364,8 +2442,6 @@ "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], - "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], @@ -2380,7 +2456,7 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="], + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -2392,13 +2468,13 @@ "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], - "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@ardatan/relay-compiler/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@babel/helper-compilation-targets/browserslist": ["browserslist@4.26.3", "", { "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", "electron-to-chromium": "^1.5.227", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -2408,16 +2484,6 @@ "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - "@emnapi/core/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@envelop/core/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@envelop/instrumentation/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@envelop/types/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], @@ -2428,63 +2494,13 @@ "@fastify/otel/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@graphql-codegen/cli/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/apollo-engine-loader/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/batch-execute/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/code-file-loader/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/delegate/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/documents/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/executor/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/executor-graphql-ws/@graphql-tools/executor-common": ["@graphql-tools/executor-common@0.0.6", "", { "dependencies": { "@envelop/core": "^5.3.0", "@graphql-tools/utils": "^10.9.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-JAH/R1zf77CSkpYATIJw+eOJwsbWocdDjY+avY7G+P5HCXxwQjAjWVkJI1QJBQYjPQDVxwf1fmTZlIN3VOadow=="], + "@graphql-tools/executor-graphql-ws/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], - "@graphql-tools/executor-graphql-ws/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/executor-graphql-ws/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], - - "@graphql-tools/executor-http/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/executor-legacy-ws/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/executor-legacy-ws/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], - - "@graphql-tools/git-loader/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/github-loader/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/graphql-file-loader/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/graphql-tag-pluck/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@graphql-tools/executor-legacy-ws/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], "@graphql-tools/import/resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], - "@graphql-tools/import/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/json-file-loader/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/load/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/merge/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/optimize/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/relay-operation-optimizer/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/schema/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/url-loader/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/url-loader/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], - - "@graphql-tools/utils/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/wrap/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@graphql-tools/url-loader/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], "@hyodotdev/openiap-kit/eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="], @@ -2496,11 +2512,11 @@ "@hyodotdev/openiap-kit/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], - "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "@hyodotdev/openiap-mcp-server/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "@node-rs/argon2-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w=="], @@ -2526,43 +2542,31 @@ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], - "@theguild/federation-composition/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], - - "@tybys/wasm-util/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@types/connect/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], + "@types/connect/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - "@types/jsonwebtoken/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], + "@types/jsonwebtoken/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - "@types/mysql/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], + "@types/mysql/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - "@types/node-fetch/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], + "@types/node-fetch/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - "@types/pg/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], + "@types/pg/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - "@types/tedious/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], + "@types/tedious/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - "@types/ws/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], + "@types/ws/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@whatwg-node/disposablestack/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "@whatwg-node/node-fetch/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "@whatwg-node/promise-helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "bun-types/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], - - "camel-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "capital-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "bun-types/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "change-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "cli-truncate/string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], + "cli-truncate/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -2572,57 +2576,39 @@ "color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "constant-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], - "cross-inspect/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "dir-glob/path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "dot-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "flat-cache/flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], - "glob/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], + "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "graphql-config/cosmiconfig": ["cosmiconfig@8.3.6", "", { "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0", "path-type": "^4.0.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA=="], - "graphql-config/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - - "graphql-config/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "graphql-tag/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "header-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "graphql-config/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "is-lower-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "is-upper-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "load-json-file/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], - "lower-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "lower-case-first/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - - "no-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -2632,31 +2618,17 @@ "npm-run-all/cross-spawn": ["cross-spawn@6.0.6", "", { "dependencies": { "nice-try": "^1.0.4", "path-key": "^2.0.1", "semver": "^5.5.0", "shebang-command": "^1.2.0", "which": "^1.2.9" } }, "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw=="], - "npm-run-all/pidtree": ["pidtree@0.3.1", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA=="], - - "param-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "pascal-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "path-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "path-scurry/lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="], - "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], - "react-helmet-async/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], - "react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], - "relay-runtime/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], - - "sentence-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "sharp-cli/sharp": ["sharp@0.34.2", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.4", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.2", "@img/sharp-darwin-x64": "0.34.2", "@img/sharp-libvips-darwin-arm64": "1.1.0", "@img/sharp-libvips-darwin-x64": "1.1.0", "@img/sharp-libvips-linux-arm": "1.1.0", "@img/sharp-libvips-linux-arm64": "1.1.0", "@img/sharp-libvips-linux-ppc64": "1.1.0", "@img/sharp-libvips-linux-s390x": "1.1.0", "@img/sharp-libvips-linux-x64": "1.1.0", "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", "@img/sharp-libvips-linuxmusl-x64": "1.1.0", "@img/sharp-linux-arm": "0.34.2", "@img/sharp-linux-arm64": "0.34.2", "@img/sharp-linux-s390x": "0.34.2", "@img/sharp-linux-x64": "0.34.2", "@img/sharp-linuxmusl-arm64": "0.34.2", "@img/sharp-linuxmusl-x64": "0.34.2", "@img/sharp-wasm32": "0.34.2", "@img/sharp-win32-arm64": "0.34.2", "@img/sharp-win32-ia32": "0.34.2", "@img/sharp-win32-x64": "0.34.2" } }, "sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg=="], @@ -2666,48 +2638,20 @@ "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - "snake-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "sponge-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "swap-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "sync-fetch/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "sync-fetch/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], - "title-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "upper-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "upper-case-first/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "vitest/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "@babel/helper-compilation-targets/browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.8.16", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw=="], - - "@babel/helper-compilation-targets/browserslist/caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="], - - "@babel/helper-compilation-targets/browserslist/electron-to-chromium": ["electron-to-chromium@1.5.237", "", {}, "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg=="], - - "@babel/helper-compilation-targets/browserslist/node-releases": ["node-releases@2.0.25", "", {}, "sha512-4auku8B/vw5psvTiiN9j1dAOsXvMoGqJuKJcR+dTdqiXEK20mMTk1UEo3HS16LeGQsVG6+qKTPM9u/qQ2LqATA=="], - - "@babel/helper-compilation-targets/browserslist/update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], - "@fastify/otel/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.212.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg=="], "@fastify/otel/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@2.0.6", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="], @@ -2718,39 +2662,37 @@ "@hyodotdev/openiap-kit/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "@hyodotdev/openiap-mcp-server/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - - "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - - "@node-rs/argon2-wasm32-wasi/@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@node-rs/bcrypt-wasm32-wasi/@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@prisma/instrumentation/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.207.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ=="], "@prisma/instrumentation/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@2.0.6", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="], - "@types/connect/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "@types/connect/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/jsonwebtoken/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "@types/jsonwebtoken/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/mysql/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "@types/mysql/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/node-fetch/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "@types/node-fetch/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/pg/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "@types/pg/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/tedious/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "@types/tedious/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/ws/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "@types/ws/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], - "bun-types/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -2810,9 +2752,17 @@ "convex/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "glob/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "graphql-config/cosmiconfig/path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], - "graphql-config/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "graphql-config/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], @@ -2874,8 +2824,6 @@ "vitest/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "wrap-ansi-cjs/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "@fastify/otel/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -2928,16 +2876,20 @@ "@inquirer/core/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "graphql-config/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "npm-run-all/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], "npm-run-all/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="], - "sharp-cli/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], - "vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -2984,12 +2936,8 @@ "vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "wrap-ansi-cjs/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "@inquirer/core/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "sharp-cli/sharp/@img/sharp-wasm32/@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], } } diff --git a/knowledge/external/webhook-mapping.md b/knowledge/external/webhook-mapping.md new file mode 100644 index 00000000..aaba7398 --- /dev/null +++ b/knowledge/external/webhook-mapping.md @@ -0,0 +1,80 @@ +# Webhook Event Mapping (ASN v2 ↔ RTDN ↔ openiap) + +This document is the source of truth for how kit normalizes Apple App Store Server +Notifications v2 (ASN v2) and Google Play Real-Time Developer Notifications (RTDN) +into the unified `WebhookEvent` shape defined in [`packages/gql/src/webhook.graphql`](../../packages/gql/src/webhook.graphql). + +When kit's webhook receivers are implemented (Phase 1, PR #2), they MUST follow +this table. When extending the spec (new event types, new stores), update this +document in the same PR. + +## Subscription lifecycle + +| openiap `WebhookEventType` | Apple ASN v2 `notificationType` (`subtype`) | Google RTDN `subscriptionNotification.notificationType` | +|---|---|---| +| `SubscriptionStarted` | `SUBSCRIBED` (`INITIAL_BUY`, `RESUBSCRIBE`) | `SUBSCRIPTION_PURCHASED` (4) | +| `SubscriptionRenewed` | `DID_RENEW` | `SUBSCRIPTION_RENEWED` (2) | +| `SubscriptionExpired` | `EXPIRED` | `SUBSCRIPTION_EXPIRED` (13) | +| `SubscriptionInGracePeriod` | `DID_FAIL_TO_RENEW` (`GRACE_PERIOD`) | `SUBSCRIPTION_IN_GRACE_PERIOD` (6) | +| `SubscriptionInBillingRetry` | `DID_FAIL_TO_RENEW` (no subtype) | `SUBSCRIPTION_ON_HOLD` (5) | +| `SubscriptionRecovered` | `DID_RENEW` (after a prior failure) | `SUBSCRIPTION_RECOVERED` (1) | +| `SubscriptionCanceled` | `DID_CHANGE_RENEWAL_STATUS` (`AUTO_RENEW_DISABLED`) | `SUBSCRIPTION_CANCELED` (3) | +| `SubscriptionUncanceled` | `DID_CHANGE_RENEWAL_STATUS` (`AUTO_RENEW_ENABLED`) | `SUBSCRIPTION_RESTARTED` (7) — fired when auto-renew is re-enabled while the period is still active | +| `SubscriptionRevoked` | `REVOKE` | `SUBSCRIPTION_REVOKED` (12) | +| `SubscriptionPriceChange` | `PRICE_INCREASE` | `SUBSCRIPTION_PRICE_CHANGE_CONFIRMED` (8), `SUBSCRIPTION_PRICE_CHANGE_UPDATED` (19) | +| `SubscriptionProductChanged` | `DID_CHANGE_RENEWAL_PREF` | `SUBSCRIPTION_DEFERRED` (9) | +| `SubscriptionPaused` | (no equivalent — iOS has no pause) | `SUBSCRIPTION_PAUSED` (10), `SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED` (11) — schedule update, not actual resume | +| `SubscriptionResumed` | (no equivalent) | `SUBSCRIPTION_RECOVERED` (1) when fired after a `SUBSCRIPTION_PAUSED` — kit chooses Resumed vs Recovered based on the prior `subscriptions` row state | + +PR #123 review caught the earlier draft where codes 1 and 4 were swapped +(`SUBSCRIPTION_RECOVERED` is code 1, `SUBSCRIPTION_PURCHASED` is code 4) +and where `SUBSCRIPTION_RESTARTED` (7) was incorrectly mapped to +`SubscriptionRecovered` instead of `SubscriptionUncanceled`. The mapping +above reflects the corrected RTDN reference. + +## One-time / common + +| openiap `WebhookEventType` | Apple ASN v2 | Google RTDN | +|---|---|---| +| `PurchaseRefunded` | `REFUND` | `oneTimeProductNotification.notificationType = ONE_TIME_PRODUCT_CANCELED` (2), or `voidedPurchaseNotification` | +| `PurchaseConsumptionRequest` | `CONSUMPTION_REQUEST` | (no equivalent — Play handles consumption client-side) | +| `TestNotification` | `TEST` | `testNotification` field present on the RTDN message | + +## Field mapping + +| `WebhookEvent` field | Apple ASN v2 source | Google RTDN source | +|---|---|---| +| `id` | `notificationUUID` | Pub/Sub `messageId` | +| `occurredAt` | `signedDate` | `eventTimeMillis` | +| `environment` | `data.environment` (`Production` \| `Sandbox` \| `Xcode`) | `testNotification` present → `Sandbox`, else `Production` | +| `purchaseToken` | `data.signedTransactionInfo.originalTransactionId` | `subscriptionNotification.purchaseToken` or `oneTimeProductNotification.purchaseToken` | +| `productId` | `data.signedTransactionInfo.productId` | `subscriptionNotification.subscriptionId` or `oneTimeProductNotification.sku` | +| `expiresAt` | `data.signedRenewalInfo.expirationDate` (decoded JWS) | resolved by calling `purchases.subscriptionsv2.get` (ASN/RTDN do not embed it directly) | +| `renewsAt` | `data.signedRenewalInfo.renewalDate` | resolved by calling `purchases.subscriptionsv2.get` | +| `cancellationReason` | `data.signedTransactionInfo.revocationReason` + ASN `subtype` | `purchases.subscriptionsv2.get` → `canceledStateContext.userInitiatedCancellation` / `systemInitiatedCancellation` | +| `currency` | `data.signedTransactionInfo.currency` | from `purchases.subscriptionsv2.get` linked product price | +| `priceAmountMicros` | `data.signedTransactionInfo.price` × 1000 (Apple's `price` field is in **milliunits** = 1/1000 of a currency unit; multiply by 1000 to convert to micros) | `purchases.subscriptionsv2.get` → `lineItems[*].autoRenewingPlan.recurringPrice` — `units * 1_000_000 + Math.round(nanos / 1000)` (Money type combines whole units + nanos = 10⁻⁹ units) | +| `rawSignedPayload` | The complete `signedPayload` JWS string from the ASN body | The base64-decoded Pub/Sub message `data` (JSON) | + +## Validation requirements (kit Phase 1, PR #2) + +Both stores require signature verification before any event is emitted: + +- **Apple ASN v2**: verify the JWS using Apple's public root certificates (refresh + via the App Store Connect API). The receiver must reject unverified payloads + with HTTP 401. +- **Google RTDN**: validate the Pub/Sub push request against the configured + service account audience (OIDC token verification). Reject missing or invalid + tokens with HTTP 401. + +Idempotency: + +- Use `(source, sourceNotificationId)` as the dedup key, where + `sourceNotificationId` is `notificationUUID` for ASN v2 or `messageId` for + RTDN. Convex idempotency table records the first-seen event and silently + acknowledges duplicates with HTTP 200. + +Replay window: + +- Events MUST be retained for at least 30 days so `webhookEventsSince` can + service reconnecting clients. Older events are pruned by a Convex cron job. diff --git a/libraries/expo-iap/example/app/_layout.tsx b/libraries/expo-iap/example/app/_layout.tsx index d3e9166d..04db206a 100644 --- a/libraries/expo-iap/example/app/_layout.tsx +++ b/libraries/expo-iap/example/app/_layout.tsx @@ -23,6 +23,10 @@ export default function RootLayout() { name="offer-code" options={{title: 'Offer Code Redemption'}} /> + ); diff --git a/libraries/expo-iap/example/app/index.tsx b/libraries/expo-iap/example/app/index.tsx index 1c1c0f07..140dec99 100644 --- a/libraries/expo-iap/example/app/index.tsx +++ b/libraries/expo-iap/example/app/index.tsx @@ -61,6 +61,14 @@ const MENU_ITEMS: MenuItem[] = [ subtitle: 'External payment links', buttonStyle: 'alternativeBillingButton', }, + { + id: 'webhook-stream', + href: '/webhook-stream', + icon: '📡', + title: 'Webhook Stream', + subtitle: 'IAPKit SSE + test notification', + buttonStyle: 'webhookStreamButton', + }, ]; /** @@ -111,6 +119,7 @@ export default function Home() { tertiaryButton: styles.tertiaryButton, quaternaryButton: styles.quaternaryButton, alternativeBillingButton: styles.alternativeBillingButton, + webhookStreamButton: styles.webhookStreamButton, }; return ( @@ -206,6 +215,9 @@ const styles = StyleSheet.create({ alternativeBillingButton: { backgroundColor: '#FF9800', }, + webhookStreamButton: { + backgroundColor: '#0EA5E9', + }, buttonText: { color: '#ffffff', fontSize: 16, diff --git a/libraries/expo-iap/example/app/webhook-stream.tsx b/libraries/expo-iap/example/app/webhook-stream.tsx new file mode 100644 index 00000000..9c29c137 --- /dev/null +++ b/libraries/expo-iap/example/app/webhook-stream.tsx @@ -0,0 +1,298 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import { + ActivityIndicator, + FlatList, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import Constants from 'expo-constants'; +import { + connectWebhookStream, + type WebhookEventPayload, + type WebhookListener, + type WebhookListenerError, +} from 'expo-iap'; + +/** + * UTF-8 safe base64 — `Buffer` is a Node global that Hermes / JSC + * don't ship, so we wrap btoa with the standard + * unescape(encodeURIComponent(...)) trick. Extracted into a helper + * because the pattern is opaque at the call site (PR #124 + * (https://github.com/hyodotdev/openiap/pull/124) review). + */ +function base64EncodeUtf8(input: string): string { + return btoa(unescape(encodeURIComponent(input))); +} + +/** + * Webhook Stream Demo + * + * Subscribes to `GET /v1/webhooks/stream/{apiKey}` (the SSE endpoint added in + * PR #124) and renders incoming `WebhookEvent`s in real time. The "Trigger + * test notification" button POSTs a synthetic event to the unified receiver + * so the round-trip can be exercised without going through Apple ASN v2 or + * Google RTDN. + */ +export default function WebhookStreamScreen() { + const apiKey: string | undefined = + (Constants.expoConfig?.extra as {iapkitApiKey?: string} | undefined) + ?.iapkitApiKey ?? process.env.EXPO_PUBLIC_IAPKIT_API_KEY; + const baseUrl = + process.env.EXPO_PUBLIC_IAPKIT_BASE_URL ?? 'https://kit.openiap.dev'; + + const [events, setEvents] = useState([]); + const [status, setStatus] = useState< + 'idle' | 'connecting' | 'connected' | 'error' + >('idle'); + const [statusMessage, setStatusMessage] = useState(null); + const [testing, setTesting] = useState(false); + const listenerRef = useRef(null); + + const startStream = useCallback(() => { + if (!apiKey) { + setStatus('error'); + setStatusMessage( + 'IAPKit API key not configured. Set EXPO_PUBLIC_IAPKIT_API_KEY in your environment.', + ); + return; + } + // Defensive close-before-open: a transient transport error keeps + // the underlying SSE auto-reconnecting in the background, so a + // user tapping Connect again in the error state would otherwise + // create a second live listener and double-emit events. + listenerRef.current?.close(); + listenerRef.current = null; + setStatus('connecting'); + setStatusMessage(null); + listenerRef.current = connectWebhookStream({ + apiKey, + baseUrl, + onEvent: (event) => { + setStatusMessage(null); + // Newest first; cap at 50 to keep the list bounded. + setEvents((prev) => [event, ...prev].slice(0, 50)); + }, + onError: (error: WebhookListenerError) => { + // Transport errors auto-reconnect under the hood; surface the + // most recent reason for visibility. + setStatus('error'); + setStatusMessage(`${error.code}: ${error.message}`); + }, + }); + // Mark connected as soon as `connectWebhookStream` returns — + // the listener is live even if no event has arrived yet. + // Waiting for the first onEvent left a healthy idle stream + // stuck in `connecting` indefinitely (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + setStatus('connected'); + }, [apiKey, baseUrl]); + + const stopStream = useCallback(() => { + listenerRef.current?.close(); + listenerRef.current = null; + setStatus('idle'); + setStatusMessage(null); + }, []); + + useEffect(() => { + return () => { + listenerRef.current?.close(); + listenerRef.current = null; + }; + }, []); + + const triggerTestNotification = useCallback(async () => { + if (!apiKey) { + setStatusMessage('Cannot trigger test: API key missing.'); + return; + } + setTesting(true); + try { + // Mirror the Pub/Sub envelope the dashboard's "Live test" button uses. + // See packages/kit/src/pages/auth/organization/project/webhooks.tsx. + const dataJson = JSON.stringify({ + version: '1.0', + packageName: 'com.example.app', + eventTimeMillis: String(Date.now()), + testNotification: {version: '1.0'}, + }); + const payload = { + message: { + data: base64EncodeUtf8(dataJson), + messageId: `expo-test-${Date.now()}`, + publishTime: new Date().toISOString(), + }, + subscription: 'projects/example/subscriptions/iapkit-rtdn', + }; + const response = await fetch( + `${baseUrl.replace(/\/$/, '')}/v1/webhooks/${encodeURIComponent( + apiKey, + )}`, + { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify(payload), + }, + ); + if (!response.ok) { + const text = await response.text(); + setStatusMessage(`Test POST returned ${response.status}: ${text}`); + } else { + setStatusMessage('Test notification accepted (200).'); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setStatusMessage(`Test POST failed: ${message}`); + } finally { + setTesting(false); + } + }, [apiKey, baseUrl]); + + return ( + + + Webhook Stream + + SSE → /v1/webhooks/stream/{`{apiKey}`} + + + base: {baseUrl} + {'\n'} + api key: {apiKey ? `${apiKey.slice(0, 8)}…` : 'MISSING'} + + + + + {status === 'idle' || status === 'error' ? ( + + Connect + + ) : ( + + Disconnect + + )} + + {testing ? ( + + ) : ( + Trigger test notification + )} + + + + + Status: {status} + {statusMessage ? ( + {statusMessage} + ) : null} + + + item.id} + ItemSeparatorComponent={() => } + ListEmptyComponent={() => ( + + + No events yet. Connect, then trigger a test notification or wait + for a real Apple / Google webhook. + + + )} + renderItem={({item}) => ( + + {item.type} + + source: {item.source ?? '—'} · platform: {item.platform ?? '—'} · + env: {item.environment ?? '—'} + + + productId: {item.productId ?? '—'} + {'\n'} + subscriptionState: {item.subscriptionState ?? '—'} + {'\n'} + receivedAt:{' '} + {item.receivedAt + ? new Date(item.receivedAt).toLocaleString() + : '—'} + + + )} + /> + + ); +} + +const styles = StyleSheet.create({ + container: {flex: 1, backgroundColor: '#ffffff'}, + header: {padding: 20, borderBottomWidth: 1, borderBottomColor: '#eaeaea'}, + title: {fontSize: 22, fontWeight: '700', color: '#000'}, + subtitle: {marginTop: 4, fontSize: 13, color: '#444', fontFamily: 'Menlo'}, + subtitleMuted: {marginTop: 8, fontSize: 12, color: '#888'}, + controls: { + flexDirection: 'row', + gap: 8, + paddingHorizontal: 20, + paddingVertical: 12, + }, + button: { + flex: 1, + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + }, + primary: {backgroundColor: '#007AFF'}, + secondary: {backgroundColor: '#FF3B30'}, + tertiary: {backgroundColor: '#34C759'}, + buttonText: {color: '#fff', fontWeight: '600'}, + statusBanner: { + marginHorizontal: 20, + marginBottom: 8, + padding: 12, + borderRadius: 8, + backgroundColor: '#f0f0f0', + }, + statusOk: {backgroundColor: '#E5F7EA'}, + statusPending: {backgroundColor: '#FFF6E0'}, + statusError: {backgroundColor: '#FDECEC'}, + statusLabel: {fontWeight: '600', color: '#222'}, + statusMessage: {marginTop: 4, color: '#444', fontSize: 12}, + list: {flex: 1}, + listContent: {padding: 20}, + separator: {height: 8}, + empty: {paddingTop: 24, alignItems: 'center'}, + emptyText: {color: '#888', textAlign: 'center'}, + eventCard: { + padding: 12, + borderRadius: 10, + backgroundColor: '#f7f8fa', + borderWidth: 1, + borderColor: '#e5e7eb', + }, + eventType: {fontSize: 14, fontWeight: '700', color: '#000'}, + eventMeta: {marginTop: 4, fontSize: 12, color: '#444'}, +}); diff --git a/libraries/expo-iap/src/__tests__/useWebhookEvents.test.ts b/libraries/expo-iap/src/__tests__/useWebhookEvents.test.ts new file mode 100644 index 00000000..650a1105 --- /dev/null +++ b/libraries/expo-iap/src/__tests__/useWebhookEvents.test.ts @@ -0,0 +1,129 @@ +/* eslint-disable import/first */ +jest.mock('react-native', () => ({ + Platform: {OS: 'ios', select: jest.fn((obj: any) => obj.ios)}, + NativeEventEmitter: jest.fn(() => ({ + addListener: jest.fn(), + removeListener: jest.fn(), + removeAllListeners: jest.fn(), + })), +})); + +import * as React from 'react'; +import * as ReactTestRenderer from 'react-test-renderer'; + +import {useWebhookEvents} from '../useWebhookEvents'; +import type {WebhookEventPayload, WebhookEventStream} from '../webhook-client'; + +const validEvent: WebhookEventPayload = { + id: 'uuid-1', + type: 'SubscriptionRenewed', + source: 'AppleAppStoreServerNotificationsV2', + platform: 'IOS', + environment: 'Production', + projectId: 'p-1', + occurredAt: 1_711_000_000_000, + receivedAt: 1_711_000_001_000, + purchaseToken: 'token-1', + productId: 'com.example.premium', + subscriptionState: 'Active', +}; + +function makeFakeStream() { + const listeners: Record< + string, + (event: {data: string; lastEventId?: string}) => void + > = {}; + const stream: WebhookEventStream = { + onmessage: null, + onerror: null, + addEventListener: (type, listener) => { + listeners[type] = listener; + }, + close: jest.fn(), + }; + return { + stream, + fire: (type: string, data: string) => listeners[type]?.({data}), + }; +} + +function HookProbe(props: Parameters[0]) { + const result = useWebhookEvents(props); + (HookProbe as any).last = result; + return null; +} + +describe('useWebhookEvents (expo)', () => { + afterEach(() => { + (HookProbe as any).last = null; + }); + + it('opens a stream and forwards events into the buffer', () => { + const {stream, fire} = makeFakeStream(); + const factory = jest.fn(() => stream); + const onEvent = jest.fn(); + + let renderer: ReturnType | null = null; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create( + React.createElement(HookProbe, { + apiKey: 'k', + baseUrl: 'http://localhost', + eventSourceFactory: factory as any, + onEvent, + }), + ); + }); + + expect(factory).toHaveBeenCalledWith( + 'http://localhost/v1/webhooks/stream/k', + {}, + ); + + ReactTestRenderer.act(() => { + fire('SubscriptionRenewed', JSON.stringify(validEvent)); + }); + + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({id: 'uuid-1'}), + ); + + const result = (HookProbe as any).last as { + events: WebhookEventPayload[]; + isConnected: boolean; + }; + expect(result.events).toHaveLength(1); + expect(result.events[0]?.id).toBe('uuid-1'); + expect(result.isConnected).toBe(true); + + ReactTestRenderer.act(() => { + renderer?.unmount(); + }); + expect(stream.close).toHaveBeenCalled(); + }); + + it('reports transport errors via onError without unmounting', () => { + const {stream} = makeFakeStream(); + const factory = jest.fn(() => stream); + const onError = jest.fn(); + + ReactTestRenderer.act(() => { + ReactTestRenderer.create( + React.createElement(HookProbe, { + apiKey: 'k', + baseUrl: 'http://localhost', + eventSourceFactory: factory as any, + onError, + }), + ); + }); + + ReactTestRenderer.act(() => { + stream.onerror?.(new Error('disconnect')); + }); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({code: 'TRANSPORT_ERROR'}), + ); + }); +}); diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index d7038da2..9d596fc2 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -1042,6 +1042,30 @@ export const verifyPurchaseWithProvider: MutationField< }; export * from './useIAP'; +export {useWebhookEvents} from './useWebhookEvents'; +export type { + UseWebhookEventsOptions, + UseWebhookEventsResult, +} from './useWebhookEvents'; +export { + connectWebhookStream, + parseWebhookEventData, +} from './webhook-client'; +export type { + WebhookEventPayload, + WebhookEventStream, + WebhookEventType as WebhookEventTypeName, + WebhookListener, + WebhookListenerError, + WebhookListenerOptions, +} from './webhook-client'; +export {kitApi, KitApiError} from './kit-api'; +export type { + KitApiOptions, + KitSubscription, + EntitlementsResponse, + StatusResponse, +} from './kit-api'; export { ErrorCodeUtils, ErrorCodeMapping, diff --git a/libraries/expo-iap/src/kit-api.ts b/libraries/expo-iap/src/kit-api.ts new file mode 100644 index 00000000..72f68247 --- /dev/null +++ b/libraries/expo-iap/src/kit-api.ts @@ -0,0 +1,225 @@ +// Tiny fetch wrapper around kit's `/v1` HTTP surface for use by the JS +// SDK consumers (react-native-iap + expo-iap). Mirrors the shape of +// `packages/mcp-server/src/kit-client.ts` so the same operations are +// reachable from both LLM tools and end-user apps without each +// duplicating the URL layout. + +export type KitApiOptions = { + apiKey: string; + baseUrl?: string; + // Optional fetch override for runtimes without a global (older RN + // builds) or for injection in tests. + fetchImpl?: (input: string, init?: RequestInit) => Promise; +}; + +export type KitSubscription = { + id: string; + productId: string; + platform: "IOS" | "Android"; + state: string; + expiresAt?: number; + renewsAt?: number; + willRenew?: boolean; + cancellationReason?: string; + currency?: string; + priceAmountMicros?: number; + startedAt: number; + updatedAt: number; + purchaseToken: string; + userId?: string; +}; + +export type EntitlementsResponse = { + userId: string; + productIds: string[]; + subscriptions: KitSubscription[]; +}; + +export type StatusResponse = { + active: boolean; + subscription: KitSubscription | null; +}; + +const DEFAULT_BASE_URL = "https://kit.openiap.dev"; + +// Merge caller-supplied headers with kit defaults (`accept`, +// optionally `content-type`). When the runtime exposes a global +// `Headers` constructor we use it directly so callers passing a +// `Headers` instance (a `HeadersInit`) keep that exact instance's +// values. When `Headers` is missing — older React Native builds where +// the operator wires up `fetchImpl` without a `Headers` polyfill — +// we fall back to a case-insensitive merge into a plain record so +// the request still goes through. Either way, caller-set values take +// precedence over kit defaults. +function mergeHeaders( + callerHeaders: HeadersInit | undefined, + hasBody: boolean, +): HeadersInit { + if (typeof Headers === "function") { + const merged = new Headers(callerHeaders); + if (!merged.has("accept")) merged.set("accept", "application/json"); + if (hasBody && !merged.has("content-type")) { + merged.set("content-type", "application/json"); + } + return merged; + } + // Plain-object fallback path. Build a case-insensitive name map + // from whatever the caller passed (Headers-shaped, array-of-pairs, + // or plain record) and re-emit as a record `fetchImpl` accepts. + const lower = new Map(); + const setIfAbsent = (name: string, value: string) => { + const key = name.toLowerCase(); + if (!lower.has(key)) lower.set(key, { name, value }); + }; + const setForce = (name: string, value: string) => { + const key = name.toLowerCase(); + lower.set(key, { name, value }); + }; + if (callerHeaders) { + if (Array.isArray(callerHeaders)) { + for (const [name, value] of callerHeaders) setForce(name, value); + } else if ( + typeof (callerHeaders as { forEach?: unknown }).forEach === "function" + ) { + // `Headers`-like (without being our `typeof Headers === "function"` + // global). RN polyfills sometimes attach `Headers` only to + // request/response instances rather than the global scope. + // Standard signature is `forEach((value, key, parent))`; we + // bind the first two positionally so a polyfill that omits + // the third argument still works. `key` is the header name. + ( + callerHeaders as { + forEach: (cb: (value: string, key: string) => void) => void; + } + ).forEach((value, key) => setForce(key, value)); + } else { + for (const [name, value] of Object.entries( + callerHeaders as Record, + )) { + setForce(name, value); + } + } + } + setIfAbsent("accept", "application/json"); + if (hasBody) setIfAbsent("content-type", "application/json"); + const out: Record = {}; + for (const { name, value } of lower.values()) out[name] = value; + return out; +} + +export class KitApiError extends Error { + constructor( + readonly status: number, + readonly body: unknown, + message: string, + ) { + super(message); + this.name = "KitApiError"; + } +} + +export function kitApi(options: KitApiOptions) { + const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ""); + const fetchImpl: (input: string, init?: RequestInit) => Promise = + (() => { + if (options.fetchImpl) return options.fetchImpl; + if (typeof fetch === "function") { + return (input: string, init?: RequestInit) => fetch(input, init); + } + throw new Error( + "kitApi requires a fetch implementation. Pass `fetchImpl` for runtimes without a global fetch.", + ); + })(); + + async function call(path: string, init?: RequestInit): Promise { + // Normalize headers without depending on a global `Headers` + // constructor: older React Native runtimes ship `fetch` (or a + // polyfill via `fetchImpl`) without exposing `Headers` globally. + // The prior implementation crashed before the first request on + // those runtimes. We use `new Headers()` when available (preserves + // caller-supplied `Headers` instances exactly), and otherwise fall + // back to a small case-insensitive merge into a plain record. + // Either way, kit defaults only apply when the caller hasn't set + // the same name. + const headers = mergeHeaders(init?.headers, init?.body != null); + // Prepend a leading slash if `path` is missing one. Today's + // call sites all hard-code the leading "/", but normalizing here + // makes the helper safe for future additions and matches the + // already-stripped `baseUrl` (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const response = await fetchImpl(`${baseUrl}${normalizedPath}`, { + ...init, + headers, + }); + const text = await response.text(); + // Empty body normalizes to null so callers expecting JSON + // (status / entitlements / list*) don't get a truthy "" + // and crash on property access. + let parsed: unknown = null; + let parseError: unknown = null; + if (text) { + try { + parsed = JSON.parse(text); + } catch (error) { + // Non-JSON body (a misconfigured proxy returning HTML, a + // CDN-injected error page, etc.) on a 2xx response would + // otherwise reach the caller as `parsed = text` and crash + // on property access via `parsed as T`. Throw a structured + // KitApiError instead so callers see a typed failure. + parseError = error; + } + } + if (!response.ok) { + // Surface the raw body (text or parsed) on the error path so + // operators can read the upstream error message verbatim. + throw new KitApiError( + response.status, + parsed ?? text, + `kit ${path} returned ${response.status}`, + ); + } + if (parseError) { + throw new KitApiError( + response.status, + text, + `kit ${path} returned a non-JSON ${response.status} body (${ + parseError instanceof Error ? parseError.message : String(parseError) + })`, + ); + } + return parsed as T; + } + + return { + apiKey: options.apiKey, + baseUrl, + + /** GET /v1/subscriptions/status — the `active` boolean is the + * fastest gate for "is this user paying?". */ + status: (userId: string) => + call( + `/v1/subscriptions/status/${encodeURIComponent(options.apiKey)}?userId=${encodeURIComponent(userId)}`, + ), + + /** GET /v1/subscriptions/entitlements — every productId the user + * is entitled to. Use this when feature gating depends on which + * specific tier the user owns. */ + entitlements: (userId: string) => + call( + `/v1/subscriptions/entitlements/${encodeURIComponent(options.apiKey)}?userId=${encodeURIComponent(userId)}`, + ), + + /** POST /v1/subscriptions/bind-user — call after a successful + * verifyReceipt so kit knows which userId owns the verified + * `purchaseToken`. Idempotent. */ + bindUser: (purchaseToken: string, userId: string) => + call<{ ok: boolean; bound: boolean }>( + `/v1/subscriptions/bind-user/${encodeURIComponent(options.apiKey)}`, + { + method: "POST", + body: JSON.stringify({ purchaseToken, userId }), + }, + ), + }; +} diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index 48f0103b..2647f51b 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -1896,6 +1896,8 @@ export interface SubscriptionProductReplacementParamsAndroid { */ export type SubscriptionReplacementModeAndroid = 'unknown-replacement-mode' | 'with-time-proration' | 'charge-prorated-price' | 'charge-full-price' | 'without-proration' | 'deferred' | 'keep-existing'; +export type SubscriptionState = 'active' | 'expired' | 'in-billing-retry' | 'in-grace-period' | 'paused' | 'refunded' | 'revoked' | 'unknown'; + export interface SubscriptionStatusIOS { renewalInfo?: (RenewalInfoIOS | null); state: string; @@ -2057,6 +2059,67 @@ export interface VerifyPurchaseWithProviderResult { export type VoidResult = void; +export type WebhookCancellationReason = 'billing-error' | 'other' | 'price-increase-declined' | 'product-unavailable' | 'refunded' | 'user-canceled'; + +export interface WebhookEvent { + /** Reason for cancellation, when applicable. */ + cancellationReason?: (WebhookCancellationReason | null); + /** Localized currency code (ISO 4217) at event time, when available. */ + currency?: (string | null); + environment: WebhookEventEnvironment; + /** When the current subscription period ends. Epoch milliseconds. */ + expiresAt?: (number | null); + /** + * Stable identifier suitable for idempotency. Derived from the source notification + * UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + * otherwise hashed from the canonicalized payload. + */ + id: string; + /** Time the underlying event occurred at the store. Epoch milliseconds. */ + occurredAt: number; + platform: IapPlatform; + /** + * Price in micros (1/1,000,000 of the currency unit) at event time, when available. + * Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + */ + priceAmountMicros?: (number | null); + /** Product the event pertains to. May be null for account-level events. */ + productId?: (string | null); + /** kit project that owns the subscription / purchase this event refers to. */ + projectId: string; + /** + * Cross-platform purchase identity used to correlate this event with an existing + * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + * Null for `TestNotification` events (Apple ASN v2 / Google RTDN test + * payloads carry no transaction); always present for every other event type. + */ + purchaseToken?: (string | null); + /** + * Original signed payload from the store. ASN v2 events expose the JWS string; + * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + * consumers can independently verify or extract platform-specific fields. kit + * always validates this payload before emitting the event. + */ + rawSignedPayload?: (string | null); + /** Time kit ingested and normalized this event. Epoch milliseconds. */ + receivedAt: number; + /** When auto-renewal will charge again. Epoch milliseconds. */ + renewsAt?: (number | null); + source: WebhookEventSource; + /** + * Normalized subscription state at the time of event, when the event refers to + * a subscription. Null for one-time purchase events. + */ + subscriptionState?: (SubscriptionState | null); + type: WebhookEventType; +} + +export type WebhookEventEnvironment = 'production' | 'sandbox' | 'xcode'; + +export type WebhookEventSource = 'apple-app-store-server-notifications-v2' | 'google-play-real-time-developer-notifications' | 'meta-horizon-reconciler'; + +export type WebhookEventType = 'purchase-consumption-request' | 'purchase-refunded' | 'subscription-canceled' | 'subscription-expired' | 'subscription-in-billing-retry' | 'subscription-in-grace-period' | 'subscription-paused' | 'subscription-price-change' | 'subscription-product-changed' | 'subscription-recovered' | 'subscription-renewed' | 'subscription-resumed' | 'subscription-revoked' | 'subscription-started' | 'subscription-uncanceled' | 'test-notification'; + /** * Win-back offer input for iOS 18+ (StoreKit 2) * Win-back offers are used to re-engage churned subscribers. diff --git a/libraries/expo-iap/src/useWebhookEvents.ts b/libraries/expo-iap/src/useWebhookEvents.ts new file mode 100644 index 00000000..96785a0c --- /dev/null +++ b/libraries/expo-iap/src/useWebhookEvents.ts @@ -0,0 +1,155 @@ +import {useEffect, useRef, useState} from 'react'; + +import { + connectWebhookStream, + type WebhookEventPayload, + type WebhookEventStream, + type WebhookListener, + type WebhookListenerError, +} from './webhook-client'; + +export type UseWebhookEventsOptions = { + apiKey: string | null | undefined; + baseUrl?: string; + eventSourceFactory?: ( + url: string, + headers: Record, + ) => WebhookEventStream; + bufferSize?: number; + onEvent?: (event: WebhookEventPayload) => void; + onError?: (error: WebhookListenerError) => void; +}; + +export type UseWebhookEventsResult = { + /** Most recent N events (most-recent-first). Capped at bufferSize. */ + events: WebhookEventPayload[]; + /** Last error reported by the underlying stream. Null when healthy. */ + lastError: WebhookListenerError | null; + /** + * True once the first webhook event has been received from the + * stream. Remains false if the connection is open but idle (the + * underlying SSE bridge doesn't surface a "stream opened" + * lifecycle event we can hook into; isConnected is therefore an + * activity indicator, not a raw socket-state flag). Reset to + * false on cleanup / apiKey change. + */ + isConnected: boolean; +}; + +// React hook wrapping the kit SSE webhook stream. See +// `libraries/react-native-iap/src/hooks/useWebhookEvents.ts` for the +// canonical version — this file mirrors it 1:1 because expo-iap and +// react-native-iap share the JS/TS SSE wire format. The intentional +// duplication keeps each library self-contained (no cross-package +// runtime dep) at the cost of a coordinated edit when the surface +// changes; that's checked by the SDK Parity Checklist in +// `knowledge/internal/04-platform-packages.md`. +export function useWebhookEvents({ + apiKey, + baseUrl, + eventSourceFactory, + bufferSize = 50, + onEvent, + onError, +}: UseWebhookEventsOptions): UseWebhookEventsResult { + const [events, setEvents] = useState([]); + const [lastError, setLastError] = useState(null); + const [isConnected, setIsConnected] = useState(false); + + const onEventRef = useRef(onEvent); + const onErrorRef = useRef(onError); + // Hold `eventSourceFactory` in a ref too so a caller passing an + // anonymous function literal (the common React pitfall) doesn't + // tear down the SSE connection on every render. We still capture + // the latest factory so a runtime-config swap (e.g. apiKey changes + // and a new EventSource constructor is needed) is honored on the + // next connect, but the *identity* of the factory no longer drives + // useEffect. + const eventSourceFactoryRef = useRef(eventSourceFactory); + // Holding bufferSize in a ref so adjusting it from the host + // component doesn't tear down the SSE connection. Same reasoning + // as onEvent / onError: a re-render with a new bufferSize would + // otherwise re-fire useEffect, close the stream, and reconnect + // (losing in-flight events the SSE handler had already buffered). + const bufferSizeRef = useRef(bufferSize); + onEventRef.current = onEvent; + onErrorRef.current = onError; + eventSourceFactoryRef.current = eventSourceFactory; + bufferSizeRef.current = bufferSize; + + // Trim the existing buffer when the host lowers `bufferSize` + // mid-stream. The ref-based update only takes effect on the next + // event arrival, which can leave the visible buffer above the new + // cap until traffic resumes — this effect enforces the cap + // immediately on the change instead. + useEffect(() => { + setEvents((prev) => (bufferSize > 0 ? prev.slice(0, bufferSize) : [])); + }, [bufferSize]); + + useEffect(() => { + // Reset surfaced state on every (re)connect target so a stale + // event from the prior stream can't briefly leak into a new + // apiKey/baseUrl context. Matches the SSE convention of + // "fresh stream → fresh history." + setEvents([]); + setLastError(null); + + if (!apiKey) { + return; + } + + let listener: WebhookListener | null = null; + let mounted = true; + + try { + listener = connectWebhookStream({ + apiKey, + baseUrl, + eventSourceFactory: eventSourceFactoryRef.current, + onEvent: (event) => { + if (!mounted) { + return; + } + setIsConnected(true); + const cap = bufferSizeRef.current; + if (cap > 0) { + setEvents((prev) => [event, ...prev].slice(0, cap)); + } + onEventRef.current?.(event); + }, + onError: (error) => { + if (!mounted) { + return; + } + setLastError(error); + onErrorRef.current?.(error); + }, + }); + } catch (error) { + const wrapped: WebhookListenerError = { + code: 'TRANSPORT_ERROR', + message: + error instanceof Error + ? error.message + : 'Failed to open webhook stream', + cause: error, + }; + setLastError(wrapped); + onErrorRef.current?.(wrapped); + } + + return () => { + mounted = false; + listener?.close(); + setIsConnected(false); + }; + // `eventSourceFactory` deliberately omitted from deps — held in a + // ref above so anonymous-function callers don't trigger reconnects + // on every render. The connection is only re-opened when apiKey or + // baseUrl changes; a runtime factory swap is picked up on that + // next reconnect via the ref. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [apiKey, baseUrl]); + + return {events, lastError, isConnected}; +} diff --git a/libraries/expo-iap/src/webhook-client.ts b/libraries/expo-iap/src/webhook-client.ts new file mode 100644 index 00000000..ad13e90d --- /dev/null +++ b/libraries/expo-iap/src/webhook-client.ts @@ -0,0 +1,312 @@ +// Transport-agnostic webhook client for the openiap kit SSE stream +// (`GET /v1/webhooks/stream/{apiKey}`). Used by the JavaScript / TS +// wrappers (react-native-iap, expo-iap) but written without React or +// React-Native imports so it can also run in plain Node, browser, or +// any other JS runtime. +// +// The wire format is documented in `packages/kit/server/api/v1/webhooks.ts` +// and matches the GraphQL `WebhookEvent` shape from `webhook.graphql`. +// +// Parser logic is split out from the connection so it can be unit- +// tested without a live server. See `webhook-client.test.ts`. + +export type WebhookEventType = + | "SubscriptionStarted" + | "SubscriptionRenewed" + | "SubscriptionExpired" + | "SubscriptionInGracePeriod" + | "SubscriptionInBillingRetry" + | "SubscriptionRecovered" + | "SubscriptionCanceled" + | "SubscriptionUncanceled" + | "SubscriptionRevoked" + | "SubscriptionPriceChange" + | "SubscriptionProductChanged" + | "SubscriptionPaused" + | "SubscriptionResumed" + | "PurchaseRefunded" + | "PurchaseConsumptionRequest" + | "TestNotification"; + +export const WEBHOOK_EVENT_TYPES = [ + "SubscriptionStarted", + "SubscriptionRenewed", + "SubscriptionExpired", + "SubscriptionInGracePeriod", + "SubscriptionInBillingRetry", + "SubscriptionRecovered", + "SubscriptionCanceled", + "SubscriptionUncanceled", + "SubscriptionRevoked", + "SubscriptionPriceChange", + "SubscriptionProductChanged", + "SubscriptionPaused", + "SubscriptionResumed", + "PurchaseRefunded", + "PurchaseConsumptionRequest", + "TestNotification", +] as const satisfies readonly WebhookEventType[]; + +export type WebhookEventPayload = { + id: string; + type: WebhookEventType; + source: string; + platform: "IOS" | "Android"; + environment: "Production" | "Sandbox" | "Xcode"; + projectId: string; + occurredAt: number; + receivedAt: number; + // Optional because TestNotification frames carry no transaction; + // every other event type populates this. + purchaseToken?: string; + productId?: string; + subscriptionState?: string; + expiresAt?: number; + renewsAt?: number; + cancellationReason?: string; + currency?: string; + priceAmountMicros?: number; + rawSignedPayload?: string; +}; + +export type WebhookListenerOptions = { + /** + * Project API key. Embedded in the URL path because Apple ASN + * registration cannot send custom headers; the same path is reused + * here for symmetry. + */ + apiKey: string; + /** + * Override the kit base URL. Defaults to https://kit.openiap.dev. + * In tests, point this at a local server. + */ + baseUrl?: string; + /** Called on every successfully-parsed webhook event. */ + onEvent: (event: WebhookEventPayload) => void; + /** + * Called on transport errors. The connection auto-reconnects + * unconditionally; this callback exists for telemetry / surfacing + * to the host UI. + */ + onError?: (error: WebhookListenerError) => void; + /** + * Optional injection of an EventSource constructor. Lets RN / + * Expo plug in `react-native-event-source` when running on a JS + * runtime that lacks the global, or vitest plug in a stub. + */ + eventSourceFactory?: ( + url: string, + headers: Record, + ) => WebhookEventStream; +}; + +export interface WebhookEventStream { + close(): void; + onmessage: ((event: { data: string; lastEventId?: string }) => void) | null; + onerror: ((error: unknown) => void) | null; + addEventListener?: ( + type: string, + listener: (event: { data: string; lastEventId?: string }) => void, + ) => void; +} + +export type WebhookListener = { + /** Tear down the connection and stop receiving events. */ + close(): void; +}; + +export type WebhookListenerError = { + code: + | "TRANSPORT_ERROR" + | "PARSE_ERROR" + | "MALFORMED_EVENT" + | "NO_EVENTSOURCE"; + message: string; + cause?: unknown; +}; + +const DEFAULT_BASE_URL = "https://kit.openiap.dev"; + +export function connectWebhookStream( + options: WebhookListenerOptions, +): WebhookListener { + const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; + const url = `${trimTrailingSlash(baseUrl)}/v1/webhooks/stream/${encodeURIComponent(options.apiKey)}`; + + const factory = options.eventSourceFactory ?? defaultEventSourceFactory; + let stream: WebhookEventStream; + try { + stream = factory(url, {}); + } catch (error) { + options.onError?.({ + code: "NO_EVENTSOURCE", + message: + error instanceof Error + ? error.message + : "EventSource constructor unavailable in this runtime", + cause: error, + }); + return { close: () => {} }; + } + + const seenIds = new Set(); + const seenOrder: string[] = []; + const markSeen = (id: string): boolean => { + if (seenIds.has(id)) { + return true; + } + seenIds.add(id); + seenOrder.push(id); + if (seenOrder.length > 1024) { + const evicted = seenOrder.shift(); + if (evicted !== undefined) { + seenIds.delete(evicted); + } + } + return false; + }; + + const handleData = (raw: string) => { + const parsed = parseWebhookEventData(raw); + if (parsed.kind === "error") { + options.onError?.({ + code: "PARSE_ERROR", + message: parsed.message, + }); + return; + } + if (parsed.kind === "skip") { + return; + } + if (markSeen(parsed.event.id)) { + return; + } + options.onEvent(parsed.event); + }; + + if (typeof stream.addEventListener === "function") { + stream.addEventListener("message", (event) => handleData(event.data)); + // WHATWG EventSource dispatches frames with `event: Foo` only to + // listeners registered for `Foo`, not to `message` / `onmessage`. + // Kit emits webhook frames as typed SSE events, so subscribe to + // every known webhook type and keep `message` for older servers or + // polyfills that collapse typed frames into the generic channel. + for (const eventType of WEBHOOK_EVENT_TYPES) { + stream.addEventListener(eventType, (event) => handleData(event.data)); + } + } else { + stream.onmessage = (event) => handleData(event.data); + } + + stream.onerror = (error) => { + options.onError?.({ + code: "TRANSPORT_ERROR", + message: "SSE transport error (auto-reconnecting)", + cause: error, + }); + }; + + return { + close: () => { + try { + stream.close(); + } catch { + // Closing an already-closed EventSource is a no-op in browsers + // but throws in some polyfills. + } + }, + }; +} + +// --------------------------------------------------------------------------- +// Pure helpers (exported for testing). +// --------------------------------------------------------------------------- + +export type ParsedEventResult = + | { kind: "ok"; event: WebhookEventPayload } + | { kind: "skip"; reason: "heartbeat" | "stream-control" } + | { kind: "error"; message: string }; + +export function parseWebhookEventData(raw: string): ParsedEventResult { + if (!raw) { + return { kind: "skip", reason: "heartbeat" }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + return { + kind: "error", + message: + error instanceof Error + ? `Failed to parse SSE payload: ${error.message}` + : "Failed to parse SSE payload", + }; + } + + if ( + typeof parsed !== "object" || + parsed === null || + !("type" in parsed) || + typeof (parsed as Record).type !== "string" + ) { + // Stream-control messages (the `ready`/`stream-error` envelopes + // emitted by the kit server) have no `type` and are surfaced as + // skips so consumers don't see them as events. + return { kind: "skip", reason: "stream-control" }; + } + + const event = parsed as WebhookEventPayload; + + if ( + typeof event.id !== "string" || + typeof event.occurredAt !== "number" || + typeof event.receivedAt !== "number" + ) { + return { + kind: "error", + message: `WebhookEvent missing required fields (id/occurredAt/receivedAt)`, + }; + } + // purchaseToken is required for every event type *except* + // TestNotification — Apple ASN v2 / Google RTDN test payloads + // carry no transaction. Hard-rejecting here would surface valid + // test webhooks as MALFORMED_EVENT and never reach listeners. + if ( + event.type !== "TestNotification" && + typeof event.purchaseToken !== "string" + ) { + return { + kind: "error", + message: `WebhookEvent missing required field purchaseToken`, + }; + } + + return { kind: "ok", event }; +} + +function trimTrailingSlash(url: string): string { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +function defaultEventSourceFactory( + url: string, + _headers: Record, +): WebhookEventStream { + // EventSource is part of the WHATWG spec and available in all + // browser environments and most JS runtimes (Bun, Node 22+, Deno). + // RN does not ship it natively — consumers must pass + // `eventSourceFactory` from `react-native-sse` or similar. + const ctor = ( + globalThis as { + EventSource?: new (url: string) => WebhookEventStream; + } + ).EventSource; + if (!ctor) { + throw new Error( + "EventSource is not defined. Pass `eventSourceFactory` for runtimes without a built-in EventSource.", + ); + } + return new ctor(url); +} diff --git a/libraries/flutter_inapp_purchase/example/lib/src/screens/subscription_flow_screen.dart b/libraries/flutter_inapp_purchase/example/lib/src/screens/subscription_flow_screen.dart index b1ef7d67..f7f43d1a 100644 --- a/libraries/flutter_inapp_purchase/example/lib/src/screens/subscription_flow_screen.dart +++ b/libraries/flutter_inapp_purchase/example/lib/src/screens/subscription_flow_screen.dart @@ -17,9 +17,16 @@ class SubscriptionFlowScreen extends StatefulWidget { State createState() => _SubscriptionFlowScreenState(); } +/// Verification method options. Mirrors the same enum on +/// `purchase_flow_screen.dart` so the subscription flow can demonstrate the +/// same Ignore / Local / IAPKit choices for renewals and upgrades. +enum VerificationMethod { ignore, local, iapkit } + class _SubscriptionFlowScreenState extends State { final FlutterInappPurchase _iap = FlutterInappPurchase.instance; + VerificationMethod _verificationMethod = VerificationMethod.ignore; + // Use subscription IDs from constants final List subscriptionIds = IapConstants.subscriptionProductIds; @@ -170,11 +177,6 @@ class _SubscriptionFlowScreenState extends State { debugPrint('✅ Purchase detected as successful, updating UI...'); debugPrint(' _isProcessing before setState: $_isProcessing'); - // Mark as processed - if (transactionKey.isNotEmpty) { - _processedTransactionIds.add(transactionKey); - } - // Update UI immediately if (mounted) { setState(() { @@ -187,17 +189,48 @@ class _SubscriptionFlowScreenState extends State { debugPrint(' ⚠️ Widget not mounted, cannot update UI'); } + // Run server / local receipt verification if the user selected one. + // Subscriptions especially benefit from IAPKit because Google Play + // does not expose `expiryTime` / grace-period / billing-retry state + // client-side — IAPKit calls `purchases.subscriptionsv2.get` server + // side and reflects the canonical state back here. We do this before + // `finishTransaction` so a failed verification doesn't quietly + // acknowledge a non-validated purchase. + var verificationOk = true; + if (_verificationMethod == VerificationMethod.iapkit) { + verificationOk = await _verifyPurchaseWithIAPKit(purchase); + } + + if (!verificationOk) { + debugPrint( + '⚠️ Skipping finishTransaction because IAPKit verification did not return isValid=true'); + // Leave the transaction unfinished so the platform retries on the + // next foreground (and don't mark `transactionKey` processed — + // the next listener emit gets a fresh chance). + return; + } + // Acknowledge/finish the transaction + var finishedOk = false; try { debugPrint('Calling finishTransaction...'); await _iap.finishTransaction( purchase: purchase, ); debugPrint('Transaction finished successfully'); + finishedOk = true; } catch (e) { debugPrint('Error finishing transaction: $e'); } + // Only mark this transactionKey processed once verification AND + // finishTransaction have both succeeded; otherwise a transient + // failure would permanently short-circuit retries for the rest + // of the session. + if (finishedOk && transactionKey.isNotEmpty) { + _processedTransactionIds.add(transactionKey); + } + // Refresh subscriptions after a short delay to ensure transaction is processed await Future.delayed(const Duration(milliseconds: 500)); debugPrint('Refreshing subscriptions...'); @@ -267,6 +300,137 @@ Has token: ${purchase.purchaseToken != null && purchase.purchaseToken!.isNotEmpt ); } + /// Verify an active subscription purchase with IAPKit. The body mirrors + /// `_verifyPurchaseWithIAPKit` in `purchase_flow_screen.dart`; subscriptions + /// reuse the same `verifyPurchaseWithProvider` API but the canonical state + /// IAPKit returns (`Active` / `InGracePeriod` / `InBillingRetry` / `Expired`) + /// is the value-add for renewals. + /// + /// Returns `true` only on a positive `isValid` from the kit response. The + /// caller is expected to skip `finishTransaction` on `false` so an unverified + /// or refunded purchase is never silently acknowledged. + Future _verifyPurchaseWithIAPKit(Purchase purchase) async { + final apiKey = IapConstants.iapkitApiKey; + debugPrint('IAPKit API key configured: ${apiKey.isNotEmpty}'); + + try { + debugPrint('Verifying subscription with IAPKit...'); + final jwsOrToken = purchase.purchaseToken ?? ''; + // Avoid logging the token itself (or a token-derived prefix) — + // ASN v2 JWS payloads and Play purchase tokens are sensitive + // and would land in adb / Xcode console + any centralized log + // collector. Log presence + length only (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + debugPrint( + 'Token for verification: present=${jwsOrToken.isNotEmpty}, length=${jwsOrToken.length}'); + + final result = await _iap.verifyPurchaseWithProvider( + provider: PurchaseVerificationProvider.Iapkit, + iapkit: RequestVerifyPurchaseWithIapkitProps( + apiKey: apiKey.isNotEmpty ? apiKey : null, + apple: RequestVerifyPurchaseWithIapkitAppleProps(jws: jwsOrToken), + google: RequestVerifyPurchaseWithIapkitGoogleProps( + purchaseToken: jwsOrToken), + ), + ); + + // Don't log the full result object — it contains the upstream + // verification payload which can include the token, productId, + // and the kit project context. Log only the high-level outcome. + debugPrint( + 'IAPKit verification completed: hasIapkit=${result.iapkit != null}'); + + if (result.iapkit != null) { + final iapkitResult = result.iapkit!; + final statusEmoji = iapkitResult.isValid ? '✅' : '⚠️'; + final stateText = iapkitResult.state.value; + + if (mounted) { + setState(() { + _purchaseResult = ''' +$_purchaseResult + +$statusEmoji IAPKit Verification +Valid: ${iapkitResult.isValid} +State: $stateText +Store: ${iapkitResult.store.value} + ''' + .trim(); + }); + } + return iapkitResult.isValid; + } + // No iapkit payload returned — treat as unverified rather than as + // a silent pass. + return false; + } catch (e) { + debugPrint('IAPKit verification failed: $e'); + if (mounted) { + setState(() { + _purchaseResult = + '$_purchaseResult\n\n❌ IAPKit verification failed: $e'; + }); + } + return false; + } + } + + void _showVerificationMethodPicker() { + showModalBottomSheet( + context: context, + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon( + _verificationMethod == VerificationMethod.ignore + ? Icons.radio_button_checked + : Icons.radio_button_off, + ), + title: const Text('Ignore'), + subtitle: const Text('Skip verification'), + onTap: () { + setState(() { + _verificationMethod = VerificationMethod.ignore; + }); + Navigator.pop(context); + }, + ), + ListTile( + leading: Icon( + _verificationMethod == VerificationMethod.iapkit + ? Icons.radio_button_checked + : Icons.radio_button_off, + ), + title: const Text('IAPKit'), + subtitle: const Text('Server-side verification via IAPKit'), + onTap: () { + setState(() { + _verificationMethod = VerificationMethod.iapkit; + }); + Navigator.pop(context); + }, + ), + ], + ), + ); + }, + ); + } + + String _getVerificationMethodLabel() { + switch (_verificationMethod) { + case VerificationMethod.ignore: + return 'Ignore'; + case VerificationMethod.local: + return 'Local'; + case VerificationMethod.iapkit: + return 'IAPKit'; + } + } + Future _initConnection() async { try { // End any existing connection first to reset configuration @@ -1336,6 +1500,33 @@ Has token: ${purchase.purchaseToken != null && purchase.purchaseToken!.isNotEmpt child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Verification Method Selector + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.shade200), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Subscription Verification:', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + OutlinedButton( + onPressed: _showVerificationMethodPicker, + child: Text(_getVerificationMethodLabel()), + ), + ], + ), + ), + const SizedBox(height: 16), + // Active Subscription Status Card Card( color: _hasActiveSubscription diff --git a/libraries/flutter_inapp_purchase/lib/enums.dart b/libraries/flutter_inapp_purchase/lib/enums.dart index bb520a0a..24d0648f 100644 --- a/libraries/flutter_inapp_purchase/lib/enums.dart +++ b/libraries/flutter_inapp_purchase/lib/enums.dart @@ -7,14 +7,12 @@ enum Store { none, playStore, amazon, appStore } /// Platform detection enum enum IapPlatform { ios, android } -/// Subscription states -enum SubscriptionState { - active, - expired, - inBillingRetry, - inGracePeriod, - revoked, -} +// `SubscriptionState` was previously hand-defined here. It now comes +// from the generated `lib/types.dart` (synced from +// `packages/gql/src/webhook.graphql`) so the values stay in lock- +// step with the openiap webhook spec — `Active / InGracePeriod / +// InBillingRetry / Expired / Revoked / Refunded / Paused / Unknown`. +// Importing both copies caused an `ambiguous_export` analyzer error. /// Transaction states enum TransactionState { purchasing, purchased, failed, restored, deferred } diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index 1645922e..210dd596 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -928,6 +928,245 @@ enum SubscriptionReplacementModeAndroid { String toJson() => value; } +enum SubscriptionState { + Active('active'), + InGracePeriod('in-grace-period'), + InBillingRetry('in-billing-retry'), + Expired('expired'), + Revoked('revoked'), + Refunded('refunded'), + Paused('paused'), + Unknown('unknown'); + + const SubscriptionState(this.value); + final String value; + + factory SubscriptionState.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'active': + return SubscriptionState.Active; + case 'in-grace-period': + return SubscriptionState.InGracePeriod; + case 'in-billing-retry': + return SubscriptionState.InBillingRetry; + case 'expired': + return SubscriptionState.Expired; + case 'revoked': + return SubscriptionState.Revoked; + case 'refunded': + return SubscriptionState.Refunded; + case 'paused': + return SubscriptionState.Paused; + case 'unknown': + return SubscriptionState.Unknown; + } + throw ArgumentError('Unknown SubscriptionState value: $value'); + } + + String toJson() => value; +} + +enum WebhookCancellationReason { + UserCanceled('user-canceled'), + BillingError('billing-error'), + PriceIncreaseDeclined('price-increase-declined'), + ProductUnavailable('product-unavailable'), + Refunded('refunded'), + Other('other'); + + const WebhookCancellationReason(this.value); + final String value; + + factory WebhookCancellationReason.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'user-canceled': + return WebhookCancellationReason.UserCanceled; + case 'billing-error': + return WebhookCancellationReason.BillingError; + case 'price-increase-declined': + return WebhookCancellationReason.PriceIncreaseDeclined; + case 'product-unavailable': + return WebhookCancellationReason.ProductUnavailable; + case 'refunded': + return WebhookCancellationReason.Refunded; + case 'other': + return WebhookCancellationReason.Other; + } + throw ArgumentError('Unknown WebhookCancellationReason value: $value'); + } + + String toJson() => value; +} + +enum WebhookEventEnvironment { + Production('production'), + Sandbox('sandbox'), + Xcode('xcode'); + + const WebhookEventEnvironment(this.value); + final String value; + + factory WebhookEventEnvironment.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'production': + return WebhookEventEnvironment.Production; + case 'sandbox': + return WebhookEventEnvironment.Sandbox; + case 'xcode': + return WebhookEventEnvironment.Xcode; + } + throw ArgumentError('Unknown WebhookEventEnvironment value: $value'); + } + + String toJson() => value; +} + +enum WebhookEventSource { + AppleAppStoreServerNotificationsV2('apple-app-store-server-notifications-v2'), + GooglePlayRealTimeDeveloperNotifications('google-play-real-time-developer-notifications'), + /// Synthetic source for Meta Horizon Store. Meta has no webhook / + /// push notification system so kit polls `verify_entitlement` on a + /// cron and emits these synthetic events when an entitlement + /// transitions. SDK consumers see them on the SSE stream alongside + /// real Apple / Google webhooks. + MetaHorizonReconciler('meta-horizon-reconciler'); + + const WebhookEventSource(this.value); + final String value; + + factory WebhookEventSource.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'apple-app-store-server-notifications-v2': + return WebhookEventSource.AppleAppStoreServerNotificationsV2; + case 'google-play-real-time-developer-notifications': + return WebhookEventSource.GooglePlayRealTimeDeveloperNotifications; + case 'meta-horizon-reconciler': + return WebhookEventSource.MetaHorizonReconciler; + } + throw ArgumentError('Unknown WebhookEventSource value: $value'); + } + + String toJson() => value; +} + +enum WebhookEventType { + /// Initial purchase or first conversion from a free trial / intro offer. + /// iOS: SUBSCRIBED (initialBuy / resubscribe). + /// Android: SUBSCRIPTION_PURCHASED. + SubscriptionStarted('subscription-started'), + /// Auto-renewal succeeded for an existing subscription. + /// iOS: DID_RENEW. + /// Android: SUBSCRIPTION_RENEWED. + SubscriptionRenewed('subscription-renewed'), + /// Subscription reached its expiration without a successful renewal. + /// iOS: EXPIRED. + /// Android: SUBSCRIPTION_EXPIRED. + SubscriptionExpired('subscription-expired'), + /// Billing failed; the subscription is in a grace period during which the user + /// retains entitlement while payment is retried. + /// iOS: DID_FAIL_TO_RENEW (with grace period active). + /// Android: SUBSCRIPTION_IN_GRACE_PERIOD. + SubscriptionInGracePeriod('subscription-in-grace-period'), + /// Billing failed and the subscription is in account-hold / billing retry, + /// during which entitlement is paused but the subscription is not yet expired. + /// iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + /// Android: SUBSCRIPTION_ON_HOLD. + SubscriptionInBillingRetry('subscription-in-billing-retry'), + /// Subscription returned to active state after a billing issue or pause. + /// iOS: DID_RECOVER. + /// Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- + /// renew re-enabled (Uncanceled), not billing recovery. + SubscriptionRecovered('subscription-recovered'), + /// User turned off auto-renew. Access continues until the current period ends. + /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). + /// Android: SUBSCRIPTION_CANCELED. + SubscriptionCanceled('subscription-canceled'), + /// User reactivated auto-renew before the subscription expired. + /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). + /// Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + SubscriptionUncanceled('subscription-uncanceled'), + /// Access immediately revoked (family sharing removal, admin action, fraud). + /// iOS: REVOKE. + /// Android: SUBSCRIPTION_REVOKED. + SubscriptionRevoked('subscription-revoked'), + /// A price change is pending or has been confirmed by the user. + /// iOS: PRICE_INCREASE. + /// Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + SubscriptionPriceChange('subscription-price-change'), + /// User upgraded, downgraded, or crossgraded their plan. + /// iOS: DID_CHANGE_RENEWAL_PREF. + /// Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + SubscriptionProductChanged('subscription-product-changed'), + /// Subscription paused (Android only feature). Also fired when the + /// pause schedule is changed — RTDN does not have a separate signal. + /// Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). + SubscriptionPaused('subscription-paused'), + /// Paused subscription resumed (Android only feature). RTDN signals + /// resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle + /// starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the + /// resume. + /// Android: SUBSCRIPTION_RECOVERED (after pause). + SubscriptionResumed('subscription-resumed'), + /// Refund issued for a one-time purchase or subscription period. + /// iOS: REFUND. + /// Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + PurchaseRefunded('purchase-refunded'), + /// iOS-only: App Store requests a consumption status report for a refund decision. + /// Servers should respond via the StoreKit consumption API. + PurchaseConsumptionRequest('purchase-consumption-request'), + /// Sandbox or test notification fired by the store for diagnostic purposes. + /// Useful for verifying webhook plumbing without a live transaction. + TestNotification('test-notification'); + + const WebhookEventType(this.value); + final String value; + + factory WebhookEventType.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'subscription-started': + return WebhookEventType.SubscriptionStarted; + case 'subscription-renewed': + return WebhookEventType.SubscriptionRenewed; + case 'subscription-expired': + return WebhookEventType.SubscriptionExpired; + case 'subscription-in-grace-period': + return WebhookEventType.SubscriptionInGracePeriod; + case 'subscription-in-billing-retry': + return WebhookEventType.SubscriptionInBillingRetry; + case 'subscription-recovered': + return WebhookEventType.SubscriptionRecovered; + case 'subscription-canceled': + return WebhookEventType.SubscriptionCanceled; + case 'subscription-uncanceled': + return WebhookEventType.SubscriptionUncanceled; + case 'subscription-revoked': + return WebhookEventType.SubscriptionRevoked; + case 'subscription-price-change': + return WebhookEventType.SubscriptionPriceChange; + case 'subscription-product-changed': + return WebhookEventType.SubscriptionProductChanged; + case 'subscription-paused': + return WebhookEventType.SubscriptionPaused; + case 'subscription-resumed': + return WebhookEventType.SubscriptionResumed; + case 'purchase-refunded': + return WebhookEventType.PurchaseRefunded; + case 'purchase-consumption-request': + return WebhookEventType.PurchaseConsumptionRequest; + case 'test-notification': + return WebhookEventType.TestNotification; + } + throw ArgumentError('Unknown WebhookEventType value: $value'); + } + + String toJson() => value; +} + // MARK: - Interfaces abstract class ProductCommon { @@ -3689,6 +3928,114 @@ class VerifyPurchaseWithProviderResult { typedef VoidResult = void; +class WebhookEvent { + const WebhookEvent({ + this.cancellationReason, + this.currency, + required this.environment, + this.expiresAt, + required this.id, + required this.occurredAt, + required this.platform, + this.priceAmountMicros, + this.productId, + required this.projectId, + this.purchaseToken, + this.rawSignedPayload, + required this.receivedAt, + this.renewsAt, + required this.source, + this.subscriptionState, + required this.type, + }); + + /// Reason for cancellation, when applicable. + final WebhookCancellationReason? cancellationReason; + /// Localized currency code (ISO 4217) at event time, when available. + final String? currency; + final WebhookEventEnvironment environment; + /// When the current subscription period ends. Epoch milliseconds. + final double? expiresAt; + /// Stable identifier suitable for idempotency. Derived from the source notification + /// UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + /// otherwise hashed from the canonicalized payload. + final String id; + /// Time the underlying event occurred at the store. Epoch milliseconds. + final double occurredAt; + final IapPlatform platform; + /// Price in micros (1/1,000,000 of the currency unit) at event time, when available. + /// Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + final double? priceAmountMicros; + /// Product the event pertains to. May be null for account-level events. + final String? productId; + /// kit project that owns the subscription / purchase this event refers to. + final String projectId; + /// Cross-platform purchase identity used to correlate this event with an existing + /// purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + /// Null for `TestNotification` events (Apple ASN v2 / Google RTDN test + /// payloads carry no transaction); always present for every other event type. + final String? purchaseToken; + /// Original signed payload from the store. ASN v2 events expose the JWS string; + /// RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + /// consumers can independently verify or extract platform-specific fields. kit + /// always validates this payload before emitting the event. + final String? rawSignedPayload; + /// Time kit ingested and normalized this event. Epoch milliseconds. + final double receivedAt; + /// When auto-renewal will charge again. Epoch milliseconds. + final double? renewsAt; + final WebhookEventSource source; + /// Normalized subscription state at the time of event, when the event refers to + /// a subscription. Null for one-time purchase events. + final SubscriptionState? subscriptionState; + final WebhookEventType type; + + factory WebhookEvent.fromJson(Map json) { + return WebhookEvent( + cancellationReason: json['cancellationReason'] != null ? WebhookCancellationReason.fromJson(json['cancellationReason'] as String) : null, + currency: json['currency'] as String?, + environment: WebhookEventEnvironment.fromJson(json['environment'] as String), + expiresAt: (json['expiresAt'] as num?)?.toDouble(), + id: json['id'] as String, + occurredAt: (json['occurredAt'] as num).toDouble(), + platform: IapPlatform.fromJson(json['platform'] as String), + priceAmountMicros: (json['priceAmountMicros'] as num?)?.toDouble(), + productId: json['productId'] as String?, + projectId: json['projectId'] as String, + purchaseToken: json['purchaseToken'] as String?, + rawSignedPayload: json['rawSignedPayload'] as String?, + receivedAt: (json['receivedAt'] as num).toDouble(), + renewsAt: (json['renewsAt'] as num?)?.toDouble(), + source: WebhookEventSource.fromJson(json['source'] as String), + subscriptionState: json['subscriptionState'] != null ? SubscriptionState.fromJson(json['subscriptionState'] as String) : null, + type: WebhookEventType.fromJson(json['type'] as String), + ); + } + + Map toJson() { + return { + '__typename': 'WebhookEvent', + 'cancellationReason': cancellationReason?.toJson(), + 'currency': currency, + 'environment': environment.toJson(), + 'expiresAt': expiresAt, + 'id': id, + 'occurredAt': occurredAt, + 'platform': platform.toJson(), + 'priceAmountMicros': priceAmountMicros, + 'productId': productId, + 'projectId': projectId, + 'purchaseToken': purchaseToken, + 'rawSignedPayload': rawSignedPayload, + 'receivedAt': receivedAt, + 'renewsAt': renewsAt, + 'source': source.toJson(), + 'subscriptionState': subscriptionState?.toJson(), + 'type': type.toJson(), + }; + } +} + // MARK: - Input Objects class AndroidSubscriptionOfferInput { diff --git a/libraries/flutter_inapp_purchase/lib/webhook_client.dart b/libraries/flutter_inapp_purchase/lib/webhook_client.dart new file mode 100644 index 00000000..4d22b050 --- /dev/null +++ b/libraries/flutter_inapp_purchase/lib/webhook_client.dart @@ -0,0 +1,428 @@ +// Webhook listener for the openiap kit SSE stream +// (`GET /v1/webhooks/stream/{apiKey}`). +// +// Wire format mirrors the canonical TypeScript implementation in +// `packages/gql/src/webhook-client.ts`. The `WebhookEvent` value type +// + enums (`WebhookEventType`, `WebhookEventSource`, `IapPlatform`, +// `SubscriptionState`, `WebhookEventEnvironment`, +// `WebhookCancellationReason`) come from the generated +// `lib/types.dart` (synced from `packages/gql/src/webhook.graphql`), +// so this file only adds: +// +// - `parseWebhookEventData` — pure JSON-string → WebhookEvent +// - `connectWebhookStream` — long-lived HTTP+SSE listener with +// auto-reconnect via `Last-Event-ID` +// +// Why a hand-rolled SSE parser instead of an http SSE package: the +// parser is small (~80 lines), matches the openiap project's +// preference for not pulling extra Dart packages into the platform +// SDKs, and gives us total control of the reconnect cadence which is +// what end-of-period billing flows actually depend on. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'types.dart'; + +/// Pure parser exported for tests so the SSE-frame → `WebhookEvent` +/// path can be validated without spinning up a real HTTP listener. +WebhookEvent? parseWebhookEventData(String raw) { + if (raw.isEmpty) return null; + Map? decoded; + try { + final value = jsonDecode(raw); + if (value is Map) decoded = value; + } catch (_) { + return null; + } + if (decoded == null) return null; + if (!decoded.containsKey('id') || + !decoded.containsKey('type') || + !decoded.containsKey('occurredAt') || + !decoded.containsKey('receivedAt')) { + return null; + } + // `purchaseToken` is required for every event type *except* + // TestNotification — Apple ASN v2 / Google RTDN test payloads carry + // no transaction, so kit emits the event with `purchaseToken` unset. + // Hard-requiring the field surfaced valid test webhooks as + // MALFORMED_EVENT in Flutter and never reached listeners. + if (decoded['type'] != 'TestNotification' && + !decoded.containsKey('purchaseToken')) { + return null; + } + // The wire format kit currently emits uses GraphQL enum identifiers + // (PascalCase, e.g. `AppleAppStoreServerNotificationsV2`). The + // generated Dart `fromJson` factories only accept the kebab-case + // wire form (`apple-app-store-server-notifications-v2`). Normalize + // each enum field here so consumers don't have to know about the + // representational difference. PR #123 (https://github.com/hyodotdev/openiap/pull/123) review caught this drift. + return _decodeWithFallback(decoded); +} + +WebhookEvent? _decodeWithFallback(Map json) { + try { + return WebhookEvent.fromJson(json); + } catch (_) { + // The kebab-case `fromJson` rejected one or more enum values; try + // again after rewriting the source/type/platform/environment/state + // /cancellationReason fields to their kebab-case equivalents. + final mapped = Map.of(json); + _rewriteEnumByName( + mapped, + 'type', + WebhookEventType.values, + (e) => e.value, + ); + _rewriteEnumByName( + mapped, + 'source', + WebhookEventSource.values, + (e) => e.value, + ); + _rewriteEnumByName( + mapped, + 'platform', + IapPlatform.values, + (e) => e.value, + ); + _rewriteEnumByName( + mapped, + 'environment', + WebhookEventEnvironment.values, + (e) => e.value, + ); + _rewriteEnumByName( + mapped, + 'subscriptionState', + SubscriptionState.values, + (e) => e.value, + ); + _rewriteEnumByName( + mapped, + 'cancellationReason', + WebhookCancellationReason.values, + (e) => e.value, + ); + try { + return WebhookEvent.fromJson(mapped); + } catch (_) { + return null; + } + } +} + +void _rewriteEnumByName( + Map json, + String field, + List values, + String Function(T) toWire, +) { + final raw = json[field]; + if (raw is! String) return; + for (final value in values) { + if (value.name == raw) { + json[field] = toWire(value); + return; + } + } +} + +/// Errors surfaced by the SSE listener. +class WebhookListenerError { + WebhookListenerError(this.code, this.message, [this.cause]); + + final String code; + final String message; + final Object? cause; + + @override + String toString() => 'WebhookListenerError($code): $message'; +} + +/// Active subscription. Cancel via [close]. +abstract class WebhookListener { + Stream get events; + Stream get errors; + Future close(); +} + +class _SseWebhookListener implements WebhookListener { + _SseWebhookListener({ + required this.apiKey, + required this.baseUrl, + required this.reconnectDelay, + HttpClient? httpClient, + }) : _httpClient = httpClient ?? HttpClient(), + _ownsHttpClient = httpClient == null; + + final String apiKey; + final String baseUrl; + final Duration reconnectDelay; + final HttpClient _httpClient; + // Only close the underlying HttpClient if we created it ourselves. + // Callers may share a single HttpClient across multiple listeners or + // unrelated request flows; force-closing a caller-owned client would + // tear down their other in-flight requests. + final bool _ownsHttpClient; + + final StreamController _events = + StreamController.broadcast(); + final StreamController _errors = + StreamController.broadcast(); + + bool _closed = false; + String? _lastEventId; + HttpClientRequest? _pendingRequest; + // Subscribe to the *decoded* string stream so utf8.decoder.bind keeps + // its buffered ByteConversionSink across HTTP chunks. Decoding each + // chunk independently with utf8.decode(allowMalformed: true) silently + // dropped partial multi-byte sequences at chunk boundaries — the + // streaming decoder defers them until the next chunk arrives. + StreamSubscription? _bodySub; + + @override + Stream get events => _events.stream; + + @override + Stream get errors => _errors.stream; + + @override + Future close() async { + _closed = true; + await _bodySub?.cancel(); + _bodySub = null; + _pendingRequest?.abort(); + _pendingRequest = null; + if (_ownsHttpClient) { + _httpClient.close(force: true); + } + await _events.close(); + await _errors.close(); + } + + // Stream-controller adds during shutdown throw "StreamSink is closed". + // Wrap every emit so a transport error / final SSE frame that races + // close() doesn't crash the listener — the consumer has already + // unsubscribed by then anyway. + void _emitError(WebhookListenerError error) { + if (_closed || _errors.isClosed) return; + _errors.add(error); + } + + void _emitEvent(WebhookEvent event) { + if (_closed || _events.isClosed) return; + _events.add(event); + } + + Future start() async { + while (!_closed) { + try { + await _runOnce(); + } catch (error, stack) { + _emitError( + WebhookListenerError( + 'TRANSPORT_ERROR', + 'SSE stream error: $error', + stack, + ), + ); + } + if (_closed) break; + await Future.delayed(reconnectDelay); + } + } + + Future _runOnce() async { + final trimmed = baseUrl.endsWith('/') + ? baseUrl.substring(0, baseUrl.length - 1) + : baseUrl; + final uri = Uri.parse( + '$trimmed/v1/webhooks/stream/${Uri.encodeComponent(apiKey)}', + ); + + final request = await _httpClient.getUrl(uri); + request.headers.set(HttpHeaders.acceptHeader, 'text/event-stream'); + if (_lastEventId != null) { + request.headers.set('Last-Event-ID', _lastEventId!); + } + _pendingRequest = request; + + final response = await request.close(); + // Accept any 2xx — kit returns 200 today but a future server / + // intermediate proxy might return 201/202/204 and the spec + // considers all of them success. The terminal-vs-transient split + // below cares about the 4xx / 5xx boundary, so any non-2xx is + // treated as failure (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + if (response.statusCode < 200 || response.statusCode >= 300) { + // 4xx responses (401 INVALID_API_KEY, 412 *_NOT_CONFIGURED) + // will never succeed on retry — surface the failure to the + // listener BEFORE flipping `_closed`, then close the loop so + // we don't spam the server with permanently-failing requests. + // The earlier ordering (`_closed = true` before throwing) + // tripped `_emitError`'s `if (_closed)` guard and silently + // swallowed terminal 401/412s. 5xx falls through to the normal + // back-off + reconnect because those are transient. Mirrors the + // Godot client's behaviour. + final status = response.statusCode; + final isTerminal = status >= 400 && status < 500; + if (isTerminal) { + _emitError( + WebhookListenerError( + 'TRANSPORT_ERROR', + 'SSE stream returned $status (terminal — not reconnecting)', + ), + ); + _closed = true; + } + throw HttpException('SSE stream returned $status'); + } + + final completer = Completer(); + final buffer = StringBuffer(); + + _bodySub = utf8.decoder.bind(response).listen( + (chunk) { + buffer.write(chunk); + _drainSseFrames(buffer); + }, + onError: (Object error, StackTrace stack) { + if (!completer.isCompleted) { + completer.completeError(error, stack); + } + }, + onDone: () { + if (!completer.isCompleted) { + completer.complete(); + } + }, + cancelOnError: true, + ); + + try { + await completer.future; + } finally { + await _bodySub?.cancel(); + _bodySub = null; + _pendingRequest = null; + } + } + + void _drainSseFrames(StringBuffer buffer) { + var content = buffer.toString(); + // Per WHATWG SSE spec, a frame separator is two consecutive line + // terminators where each terminator is CR, LF, or CRLF. The prior + // `\r?\n\r?\n` only covered LF / CRLF and silently dropped frames + // from CR-only servers (rare but spec-allowed). + final frameSeparator = RegExp(r'(\r\n|\r|\n){2}'); + while (true) { + final match = frameSeparator.firstMatch(content); + if (match == null) break; + final frame = content.substring(0, match.start); + content = content.substring(match.end); + _processFrame(frame); + } + buffer + ..clear() + ..write(content); + } + + void _processFrame(String frame) { + if (frame.isEmpty) return; + String? eventName; + String? eventId; + final dataLines = []; + // Split on any of CR / LF / CRLF (matches the spec's line terminator). + for (final rawLine in frame.split(RegExp(r'\r\n|\r|\n'))) { + if (rawLine.startsWith(':')) continue; // SSE comment + final colonIdx = rawLine.indexOf(':'); + if (colonIdx < 0) continue; + // Per SSE spec the field name is everything before the first + // colon (verbatim, no trim) and only a single leading space is + // stripped from the value. Trimming the field name would + // mis-key fields if a server emitted accidental whitespace. + final field = rawLine.substring(0, colonIdx); + var value = rawLine.substring(colonIdx + 1); + if (value.startsWith(' ')) value = value.substring(1); + switch (field) { + case 'event': + eventName = value; + break; + case 'id': + eventId = value; + break; + case 'data': + dataLines.add(value); + break; + } + } + // Don't advance `_lastEventId` here — wait until we actually + // accept the event below. If the frame is malformed, advancing + // before the parse would move the reconnect cursor past an event + // that we never delivered to the consumer, so the next connection + // would skip it permanently. + if (dataLines.isEmpty) return; + final dataStr = dataLines.join('\n'); + if (dataStr.isEmpty) return; + if (eventName == 'heartbeat' || eventName == 'ready') return; + + // The kit server emits `event: stream-error` with a JSON `{message}` + // payload when the backend Convex subscription itself fails (e.g. the + // project's API key was rotated mid-stream). Surface those as a + // distinct error code so callers can react — falling through to + // `parseWebhookEventData` would mis-report it as MALFORMED_EVENT. + if (eventName == 'stream-error') { + String message = dataStr; + try { + final decoded = jsonDecode(dataStr); + if (decoded is Map && decoded['message'] is String) { + message = decoded['message'] as String; + } + } catch (_) { + // Fall back to raw frame body. + } + _emitError(WebhookListenerError('STREAM_ERROR', message)); + return; + } + + final event = parseWebhookEventData(dataStr); + if (event == null) { + _emitError( + WebhookListenerError( + 'MALFORMED_EVENT', + 'WebhookEvent missing required fields or unknown type', + ), + ); + return; + } + _emitEvent(event); + // Cursor advances only on successful enqueue. The reconnect path + // resumes strictly past the last event we actually surfaced. + if (eventId != null && eventId.isNotEmpty) { + _lastEventId = eventId; + } + } +} + +/// Open a long-lived listener against the kit SSE stream. The +/// listener auto-reconnects with `Last-Event-ID` until [close] is +/// called. +WebhookListener connectWebhookStream({ + required String apiKey, + String baseUrl = 'https://kit.openiap.dev', + Duration reconnectDelay = const Duration(seconds: 2), + HttpClient? httpClient, +}) { + final listener = _SseWebhookListener( + apiKey: apiKey, + baseUrl: baseUrl, + reconnectDelay: reconnectDelay, + httpClient: httpClient, + ); + // Fire-and-forget the loop; consumers gate via [close]. + // ignore: unawaited_futures + listener.start(); + return listener; +} diff --git a/libraries/flutter_inapp_purchase/test/webhook_client_test.dart b/libraries/flutter_inapp_purchase/test/webhook_client_test.dart new file mode 100644 index 00000000..c2a1fa26 --- /dev/null +++ b/libraries/flutter_inapp_purchase/test/webhook_client_test.dart @@ -0,0 +1,85 @@ +import 'dart:convert'; + +import 'package:flutter_inapp_purchase/types.dart'; +import 'package:flutter_inapp_purchase/webhook_client.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('parseWebhookEventData', () { + test('parses a complete event payload', () { + final raw = jsonEncode({ + 'id': 'uuid-1', + 'type': 'SubscriptionRenewed', + 'source': 'AppleAppStoreServerNotificationsV2', + 'platform': 'IOS', + 'environment': 'Production', + 'projectId': 'p-1', + 'occurredAt': 1711000000000, + 'receivedAt': 1711000001000, + 'purchaseToken': 'token-1', + 'productId': 'com.example.premium', + 'subscriptionState': 'Active', + }); + final event = parseWebhookEventData(raw)!; + expect(event.id, 'uuid-1'); + expect(event.type, WebhookEventType.SubscriptionRenewed); + expect(event.purchaseToken, 'token-1'); + expect(event.productId, 'com.example.premium'); + }); + + test('returns null for empty / non-JSON / malformed input', () { + expect(parseWebhookEventData(''), isNull); + expect(parseWebhookEventData('not json'), isNull); + // Required fields missing + expect( + parseWebhookEventData(jsonEncode({'type': 'SubscriptionRenewed'})), + isNull, + ); + }); + + test('parses TestNotification without purchaseToken', () { + // TestNotification is the one payload shape kit ingests without a + // purchaseToken — Apple's "send a test notification" button + the + // dashboard's "Live test" button both emit it. Regression-guard + // here so a future tightening of "required fields" doesn't turn + // valid test webhooks back into MALFORMED_EVENT. + final raw = jsonEncode({ + 'id': 'uuid-test', + 'type': 'TestNotification', + 'source': 'AppleAppStoreServerNotificationsV2', + 'platform': 'IOS', + 'environment': 'Sandbox', + 'projectId': 'p-1', + 'occurredAt': 1, + 'receivedAt': 2, + // no purchaseToken — intentional + }); + final event = parseWebhookEventData(raw)!; + expect(event.type, WebhookEventType.TestNotification); + // `purchaseToken` is nullable on the generated WebhookEvent + // because TestNotification is the one shape kit ingests without + // it; the parser must not synthesize an empty string. + expect(event.purchaseToken, isNull); + }); + + test('rejects payloads with unknown event types', () { + // PR #123 (https://github.com/hyodotdev/openiap/pull/123) review: lenient mapping to a synthetic `Unknown` enum + // hides spec drift between kit and the SDK consumers. Generated + // `WebhookEventType.fromJson` throws for unknown values; the + // parser catches that and returns null so the SSE listener can + // surface MALFORMED_EVENT instead of emitting a synthetic row. + final raw = jsonEncode({ + 'id': 'uuid-2', + 'type': 'SomethingNew', + 'source': 'AppleAppStoreServerNotificationsV2', + 'platform': 'IOS', + 'environment': 'Production', + 'projectId': 'p-1', + 'occurredAt': 1, + 'receivedAt': 2, + 'purchaseToken': 't', + }); + expect(parseWebhookEventData(raw), isNull); + }); + }); +} diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index bc96928f..416cd5fb 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -295,6 +295,74 @@ enum SubscriptionReplacementModeAndroid { KEEP_EXISTING = 6, } +enum SubscriptionState { + ACTIVE = 0, + IN_GRACE_PERIOD = 1, + IN_BILLING_RETRY = 2, + EXPIRED = 3, + REVOKED = 4, + REFUNDED = 5, + PAUSED = 6, + UNKNOWN = 7, +} + +enum WebhookCancellationReason { + USER_CANCELED = 0, + BILLING_ERROR = 1, + PRICE_INCREASE_DECLINED = 2, + PRODUCT_UNAVAILABLE = 3, + REFUNDED = 4, + OTHER = 5, +} + +enum WebhookEventEnvironment { + PRODUCTION = 0, + SANDBOX = 1, + XCODE = 2, +} + +enum WebhookEventSource { + APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2 = 0, + GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS = 1, + ## Synthetic source for Meta Horizon Store. Meta has no webhook / push notification system so kit polls `verify_entitlement` on a cron and emits these synthetic events when an entitlement transitions. SDK consumers see them on the SSE stream alongside real Apple / Google webhooks. + META_HORIZON_RECONCILER = 2, +} + +enum WebhookEventType { + ## Initial purchase or first conversion from a free trial / intro offer. iOS: SUBSCRIBED (initialBuy / resubscribe). Android: SUBSCRIPTION_PURCHASED. + SUBSCRIPTION_STARTED = 0, + ## Auto-renewal succeeded for an existing subscription. iOS: DID_RENEW. Android: SUBSCRIPTION_RENEWED. + SUBSCRIPTION_RENEWED = 1, + ## Subscription reached its expiration without a successful renewal. iOS: EXPIRED. Android: SUBSCRIPTION_EXPIRED. + SUBSCRIPTION_EXPIRED = 2, + ## Billing failed; the subscription is in a grace period during which the user retains entitlement while payment is retried. iOS: DID_FAIL_TO_RENEW (with grace period active). Android: SUBSCRIPTION_IN_GRACE_PERIOD. + SUBSCRIPTION_IN_GRACE_PERIOD = 3, + ## Billing failed and the subscription is in account-hold / billing retry, during which entitlement is paused but the subscription is not yet expired. iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). Android: SUBSCRIPTION_ON_HOLD. + SUBSCRIPTION_IN_BILLING_RETRY = 4, + ## Subscription returned to active state after a billing issue or pause. iOS: DID_RECOVER. Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- renew re-enabled (Uncanceled), not billing recovery. + SUBSCRIPTION_RECOVERED = 5, + ## User turned off auto-renew. Access continues until the current period ends. iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). Android: SUBSCRIPTION_CANCELED. + SUBSCRIPTION_CANCELED = 6, + ## User reactivated auto-renew before the subscription expired. iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + SUBSCRIPTION_UNCANCELED = 7, + ## Access immediately revoked (family sharing removal, admin action, fraud). iOS: REVOKE. Android: SUBSCRIPTION_REVOKED. + SUBSCRIPTION_REVOKED = 8, + ## A price change is pending or has been confirmed by the user. iOS: PRICE_INCREASE. Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + SUBSCRIPTION_PRICE_CHANGE = 9, + ## User upgraded, downgraded, or crossgraded their plan. iOS: DID_CHANGE_RENEWAL_PREF. Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + SUBSCRIPTION_PRODUCT_CHANGED = 10, + ## Subscription paused (Android only feature). Also fired when the pause schedule is changed — RTDN does not have a separate signal. Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). + SUBSCRIPTION_PAUSED = 11, + ## Paused subscription resumed (Android only feature). RTDN signals resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the resume. Android: SUBSCRIPTION_RECOVERED (after pause). + SUBSCRIPTION_RESUMED = 12, + ## Refund issued for a one-time purchase or subscription period. iOS: REFUND. Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + PURCHASE_REFUNDED = 13, + ## iOS-only: App Store requests a consumption status report for a refund decision. Servers should respond via the StoreKit consumption API. + PURCHASE_CONSUMPTION_REQUEST = 14, + ## Sandbox or test notification fired by the store for diagnostic purposes. Useful for verifying webhook plumbing without a live transaction. + TEST_NOTIFICATION = 15, +} + # ============================================================================ # Types # ============================================================================ @@ -3238,6 +3306,146 @@ class VoidResult: dict["success"] = success return dict +class WebhookEvent: + ## Stable identifier suitable for idempotency. Derived from the source notification + var id: String = "" + var type: WebhookEventType + var source: WebhookEventSource + var platform: IapPlatform + ## kit project that owns the subscription / purchase this event refers to. + var project_id: String = "" + ## Time the underlying event occurred at the store. Epoch milliseconds. + var occurred_at: float = 0.0 + ## Time kit ingested and normalized this event. Epoch milliseconds. + var received_at: float = 0.0 + var environment: WebhookEventEnvironment + ## Cross-platform purchase identity used to correlate this event with an existing + var purchase_token: Variant = null + ## Product the event pertains to. May be null for account-level events. + var product_id: Variant = null + ## Normalized subscription state at the time of event, when the event refers to + var subscription_state: SubscriptionState + ## When the current subscription period ends. Epoch milliseconds. + var expires_at: Variant = null + ## When auto-renewal will charge again. Epoch milliseconds. + var renews_at: Variant = null + ## Reason for cancellation, when applicable. + var cancellation_reason: WebhookCancellationReason + ## Localized currency code (ISO 4217) at event time, when available. + var currency: Variant = null + ## Price in micros (1/1,000,000 of the currency unit) at event time, when available. + var price_amount_micros: Variant = null + ## Original signed payload from the store. ASN v2 events expose the JWS string; + var raw_signed_payload: Variant = null + + static func from_dict(data: Dictionary) -> WebhookEvent: + var obj = WebhookEvent.new() + if data.has("id") and data["id"] != null: + obj.id = data["id"] + if data.has("type") and data["type"] != null: + var enum_str = data["type"] + if enum_str is String and WEBHOOK_EVENT_TYPE_FROM_STRING.has(enum_str): + obj.type = WEBHOOK_EVENT_TYPE_FROM_STRING[enum_str] + else: + obj.type = enum_str + if data.has("source") and data["source"] != null: + var enum_str = data["source"] + if enum_str is String and WEBHOOK_EVENT_SOURCE_FROM_STRING.has(enum_str): + obj.source = WEBHOOK_EVENT_SOURCE_FROM_STRING[enum_str] + else: + obj.source = enum_str + if data.has("platform") and data["platform"] != null: + var enum_str = data["platform"] + if enum_str is String and IAP_PLATFORM_FROM_STRING.has(enum_str): + obj.platform = IAP_PLATFORM_FROM_STRING[enum_str] + else: + obj.platform = enum_str + if data.has("projectId") and data["projectId"] != null: + obj.project_id = data["projectId"] + if data.has("occurredAt") and data["occurredAt"] != null: + obj.occurred_at = data["occurredAt"] + if data.has("receivedAt") and data["receivedAt"] != null: + obj.received_at = data["receivedAt"] + if data.has("environment") and data["environment"] != null: + var enum_str = data["environment"] + if enum_str is String and WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING.has(enum_str): + obj.environment = WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING[enum_str] + else: + obj.environment = enum_str + if data.has("purchaseToken") and data["purchaseToken"] != null: + obj.purchase_token = data["purchaseToken"] + if data.has("productId") and data["productId"] != null: + obj.product_id = data["productId"] + if data.has("subscriptionState") and data["subscriptionState"] != null: + var enum_str = data["subscriptionState"] + if enum_str is String and SUBSCRIPTION_STATE_FROM_STRING.has(enum_str): + obj.subscription_state = SUBSCRIPTION_STATE_FROM_STRING[enum_str] + else: + obj.subscription_state = enum_str + if data.has("expiresAt") and data["expiresAt"] != null: + obj.expires_at = data["expiresAt"] + if data.has("renewsAt") and data["renewsAt"] != null: + obj.renews_at = data["renewsAt"] + if data.has("cancellationReason") and data["cancellationReason"] != null: + var enum_str = data["cancellationReason"] + if enum_str is String and WEBHOOK_CANCELLATION_REASON_FROM_STRING.has(enum_str): + obj.cancellation_reason = WEBHOOK_CANCELLATION_REASON_FROM_STRING[enum_str] + else: + obj.cancellation_reason = enum_str + if data.has("currency") and data["currency"] != null: + obj.currency = data["currency"] + if data.has("priceAmountMicros") and data["priceAmountMicros"] != null: + obj.price_amount_micros = data["priceAmountMicros"] + if data.has("rawSignedPayload") and data["rawSignedPayload"] != null: + obj.raw_signed_payload = data["rawSignedPayload"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["id"] = id + if WEBHOOK_EVENT_TYPE_VALUES.has(type): + dict["type"] = WEBHOOK_EVENT_TYPE_VALUES[type] + else: + dict["type"] = type + if WEBHOOK_EVENT_SOURCE_VALUES.has(source): + dict["source"] = WEBHOOK_EVENT_SOURCE_VALUES[source] + else: + dict["source"] = source + if IAP_PLATFORM_VALUES.has(platform): + dict["platform"] = IAP_PLATFORM_VALUES[platform] + else: + dict["platform"] = platform + dict["projectId"] = project_id + dict["occurredAt"] = occurred_at + dict["receivedAt"] = received_at + if WEBHOOK_EVENT_ENVIRONMENT_VALUES.has(environment): + dict["environment"] = WEBHOOK_EVENT_ENVIRONMENT_VALUES[environment] + else: + dict["environment"] = environment + if purchase_token != null: + dict["purchaseToken"] = purchase_token + if product_id != null: + dict["productId"] = product_id + if SUBSCRIPTION_STATE_VALUES.has(subscription_state): + dict["subscriptionState"] = SUBSCRIPTION_STATE_VALUES[subscription_state] + else: + dict["subscriptionState"] = subscription_state + if expires_at != null: + dict["expiresAt"] = expires_at + if renews_at != null: + dict["renewsAt"] = renews_at + if WEBHOOK_CANCELLATION_REASON_VALUES.has(cancellation_reason): + dict["cancellationReason"] = WEBHOOK_CANCELLATION_REASON_VALUES[cancellation_reason] + else: + dict["cancellationReason"] = cancellation_reason + if currency != null: + dict["currency"] = currency + if price_amount_micros != null: + dict["priceAmountMicros"] = price_amount_micros + if raw_signed_payload != null: + dict["rawSignedPayload"] = raw_signed_payload + return dict + # ============================================================================ # Input Types # ============================================================================ @@ -4569,6 +4777,57 @@ const SUBSCRIPTION_REPLACEMENT_MODE_ANDROID_VALUES = { SubscriptionReplacementModeAndroid.KEEP_EXISTING: "keep-existing" } +const SUBSCRIPTION_STATE_VALUES = { + SubscriptionState.ACTIVE: "active", + SubscriptionState.IN_GRACE_PERIOD: "in-grace-period", + SubscriptionState.IN_BILLING_RETRY: "in-billing-retry", + SubscriptionState.EXPIRED: "expired", + SubscriptionState.REVOKED: "revoked", + SubscriptionState.REFUNDED: "refunded", + SubscriptionState.PAUSED: "paused", + SubscriptionState.UNKNOWN: "unknown" +} + +const WEBHOOK_CANCELLATION_REASON_VALUES = { + WebhookCancellationReason.USER_CANCELED: "user-canceled", + WebhookCancellationReason.BILLING_ERROR: "billing-error", + WebhookCancellationReason.PRICE_INCREASE_DECLINED: "price-increase-declined", + WebhookCancellationReason.PRODUCT_UNAVAILABLE: "product-unavailable", + WebhookCancellationReason.REFUNDED: "refunded", + WebhookCancellationReason.OTHER: "other" +} + +const WEBHOOK_EVENT_ENVIRONMENT_VALUES = { + WebhookEventEnvironment.PRODUCTION: "production", + WebhookEventEnvironment.SANDBOX: "sandbox", + WebhookEventEnvironment.XCODE: "xcode" +} + +const WEBHOOK_EVENT_SOURCE_VALUES = { + WebhookEventSource.APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2: "apple-app-store-server-notifications-v2", + WebhookEventSource.GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS: "google-play-real-time-developer-notifications", + WebhookEventSource.META_HORIZON_RECONCILER: "meta-horizon-reconciler" +} + +const WEBHOOK_EVENT_TYPE_VALUES = { + WebhookEventType.SUBSCRIPTION_STARTED: "subscription-started", + WebhookEventType.SUBSCRIPTION_RENEWED: "subscription-renewed", + WebhookEventType.SUBSCRIPTION_EXPIRED: "subscription-expired", + WebhookEventType.SUBSCRIPTION_IN_GRACE_PERIOD: "subscription-in-grace-period", + WebhookEventType.SUBSCRIPTION_IN_BILLING_RETRY: "subscription-in-billing-retry", + WebhookEventType.SUBSCRIPTION_RECOVERED: "subscription-recovered", + WebhookEventType.SUBSCRIPTION_CANCELED: "subscription-canceled", + WebhookEventType.SUBSCRIPTION_UNCANCELED: "subscription-uncanceled", + WebhookEventType.SUBSCRIPTION_REVOKED: "subscription-revoked", + WebhookEventType.SUBSCRIPTION_PRICE_CHANGE: "subscription-price-change", + WebhookEventType.SUBSCRIPTION_PRODUCT_CHANGED: "subscription-product-changed", + WebhookEventType.SUBSCRIPTION_PAUSED: "subscription-paused", + WebhookEventType.SUBSCRIPTION_RESUMED: "subscription-resumed", + WebhookEventType.PURCHASE_REFUNDED: "purchase-refunded", + WebhookEventType.PURCHASE_CONSUMPTION_REQUEST: "purchase-consumption-request", + WebhookEventType.TEST_NOTIFICATION: "test-notification" +} + # ============================================================================ # Enum Reverse Lookup (string -> enum for deserialization) # ============================================================================ @@ -4787,6 +5046,57 @@ const SUBSCRIPTION_REPLACEMENT_MODE_ANDROID_FROM_STRING = { "keep-existing": SubscriptionReplacementModeAndroid.KEEP_EXISTING } +const SUBSCRIPTION_STATE_FROM_STRING = { + "active": SubscriptionState.ACTIVE, + "in-grace-period": SubscriptionState.IN_GRACE_PERIOD, + "in-billing-retry": SubscriptionState.IN_BILLING_RETRY, + "expired": SubscriptionState.EXPIRED, + "revoked": SubscriptionState.REVOKED, + "refunded": SubscriptionState.REFUNDED, + "paused": SubscriptionState.PAUSED, + "unknown": SubscriptionState.UNKNOWN +} + +const WEBHOOK_CANCELLATION_REASON_FROM_STRING = { + "user-canceled": WebhookCancellationReason.USER_CANCELED, + "billing-error": WebhookCancellationReason.BILLING_ERROR, + "price-increase-declined": WebhookCancellationReason.PRICE_INCREASE_DECLINED, + "product-unavailable": WebhookCancellationReason.PRODUCT_UNAVAILABLE, + "refunded": WebhookCancellationReason.REFUNDED, + "other": WebhookCancellationReason.OTHER +} + +const WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING = { + "production": WebhookEventEnvironment.PRODUCTION, + "sandbox": WebhookEventEnvironment.SANDBOX, + "xcode": WebhookEventEnvironment.XCODE +} + +const WEBHOOK_EVENT_SOURCE_FROM_STRING = { + "apple-app-store-server-notifications-v2": WebhookEventSource.APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2, + "google-play-real-time-developer-notifications": WebhookEventSource.GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS, + "meta-horizon-reconciler": WebhookEventSource.META_HORIZON_RECONCILER +} + +const WEBHOOK_EVENT_TYPE_FROM_STRING = { + "subscription-started": WebhookEventType.SUBSCRIPTION_STARTED, + "subscription-renewed": WebhookEventType.SUBSCRIPTION_RENEWED, + "subscription-expired": WebhookEventType.SUBSCRIPTION_EXPIRED, + "subscription-in-grace-period": WebhookEventType.SUBSCRIPTION_IN_GRACE_PERIOD, + "subscription-in-billing-retry": WebhookEventType.SUBSCRIPTION_IN_BILLING_RETRY, + "subscription-recovered": WebhookEventType.SUBSCRIPTION_RECOVERED, + "subscription-canceled": WebhookEventType.SUBSCRIPTION_CANCELED, + "subscription-uncanceled": WebhookEventType.SUBSCRIPTION_UNCANCELED, + "subscription-revoked": WebhookEventType.SUBSCRIPTION_REVOKED, + "subscription-price-change": WebhookEventType.SUBSCRIPTION_PRICE_CHANGE, + "subscription-product-changed": WebhookEventType.SUBSCRIPTION_PRODUCT_CHANGED, + "subscription-paused": WebhookEventType.SUBSCRIPTION_PAUSED, + "subscription-resumed": WebhookEventType.SUBSCRIPTION_RESUMED, + "purchase-refunded": WebhookEventType.PURCHASE_REFUNDED, + "purchase-consumption-request": WebhookEventType.PURCHASE_CONSUMPTION_REQUEST, + "test-notification": WebhookEventType.TEST_NOTIFICATION +} + # ============================================================================ # Query Types # ============================================================================ diff --git a/libraries/godot-iap/addons/godot-iap/webhook_client.gd b/libraries/godot-iap/addons/godot-iap/webhook_client.gd new file mode 100644 index 00000000..56f8f6a3 --- /dev/null +++ b/libraries/godot-iap/addons/godot-iap/webhook_client.gd @@ -0,0 +1,321 @@ +extends Node +class_name OpenIapWebhookClient + +## Webhook listener for the openiap kit SSE stream +## (`GET /v1/webhooks/stream/{api_key}`). +## +## Wire format mirrors the canonical TypeScript implementation in +## packages/gql/src/webhook-client.ts. The WebhookEvent shape comes +## from packages/gql/src/webhook.graphql. +## +## Add this node to a scene, set [code]api_key[/code] (and optionally +## [code]base_url[/code]), then call [code]connect_stream()[/code]. +## Listen for the [code]event_received[/code] signal to consume +## normalized webhook events without running your own server. +## +## Reconnect: the node uses HTTPClient + chunked HTTP read in a loop +## with a 2s back-off on disconnect. The Last-Event-ID header is +## populated from the most recently dispatched event so events that +## fire while the connection is closed are delivered in order on the +## next connect. + +@export var api_key: String = "" +@export var base_url: String = "https://kit.openiap.dev" +@export var auto_start: bool = false +@export var reconnect_delay_seconds: float = 2.0 + +signal event_received(event: Dictionary) +signal stream_error(code: String, message: String) +signal connected_to_stream() + +var _client: HTTPClient = HTTPClient.new() +var _running: bool = false +var _last_event_id: String = "" +# Byte-oriented buffer so a multi-byte UTF-8 character split across two +# HTTP chunks doesn't corrupt the decode. We only call +# `get_string_from_utf8()` after the SSE frame separator is found — +# `\n` / `\r` are pure ASCII so scanning for the boundary at the byte +# level can't false-match inside a multi-byte codepoint. +var _buffer: PackedByteArray = PackedByteArray() + +func _ready() -> void: + if auto_start: + connect_stream() + +## Begin streaming. Returns immediately; the connection runs on a +## background process loop until [code]close_stream()[/code] is called +## or the node is freed. +func connect_stream() -> void: + if _running: + return + if api_key.is_empty(): + emit_signal("stream_error", "INVALID_INPUT", "api_key is empty") + return + _running = true + _run_loop() + +func close_stream() -> void: + _running = false + _client.close() + +# Stop the SSE coroutine when the node leaves the scene tree. Without +# this, callers that queue_free()/remove_child() this node without +# explicitly calling close_stream() leak the awaited +# SceneTreeTimer (the timer outlives the node) and the resume path +# crashes when the awaiting object is gone. Mirrors close_stream() +# so the cleanup logic stays in one place. +func _exit_tree() -> void: + close_stream() + +func _run_loop() -> void: + while _running: + var ok := await _open_and_drain() + # Skip the reconnect-error signal if shutdown was intentional — + # `close_stream()` / `_exit_tree()` set `_running = false` + # before _open_and_drain returns, and emitting TRANSPORT_ERROR + # in that path made normal teardown look like a failure + # (PR #124 (https://github.com/hyodotdev/openiap/pull/124) + # review). + if not ok and _running: + emit_signal("stream_error", "TRANSPORT_ERROR", "stream disconnected; reconnecting") + if not _running: + break + await get_tree().create_timer(reconnect_delay_seconds).timeout + +func _open_and_drain() -> bool: + var trimmed := base_url.trim_suffix("/") + var parsed_uri := trimmed.replace("https://", "").replace("http://", "") + var slash := parsed_uri.find("/") + var host: String + var path_root: String + if slash >= 0: + host = parsed_uri.substr(0, slash) + path_root = parsed_uri.substr(slash) + else: + host = parsed_uri + path_root = "" + var use_ssl := not trimmed.begins_with("http://") + var port := 443 if use_ssl else 80 + # Honor an explicit `host:port` override regardless of scheme. + # Prior behaviour only parsed the port for non-TLS URLs, so a + # custom HTTPS endpoint like `https://kit.example.com:8443` was + # silently dialled on 443 and the stream never opened. + var colon := host.find(":") + if colon >= 0: + port = int(host.substr(colon + 1)) + host = host.substr(0, colon) + + var connect_err := _client.connect_to_host(host, port, TLSOptions.client() if use_ssl else null) + if connect_err != OK: + return false + + while _client.get_status() == HTTPClient.STATUS_CONNECTING or _client.get_status() == HTTPClient.STATUS_RESOLVING: + _client.poll() + await get_tree().process_frame + + if _client.get_status() != HTTPClient.STATUS_CONNECTED: + # Detect terminal HTTPClient statuses (DNS resolution failure, + # unreachable host, broken TLS) so a misconfigured endpoint + # doesn't trigger an infinite reconnect loop. Surface the + # specific failure so the operator can fix the config instead + # of seeing a generic "stream disconnected" log spam every 2s + # (PR #124 (https://github.com/hyodotdev/openiap/pull/124) + # review). + var status := _client.get_status() + if status == HTTPClient.STATUS_CANT_RESOLVE \ + or status == HTTPClient.STATUS_CANT_CONNECT \ + or status == HTTPClient.STATUS_TLS_HANDSHAKE_ERROR: + emit_signal("stream_error", "HTTP_CLIENT_FATAL", "HTTPClient terminal status: %d" % status) + _running = false + _client.close() + return false + + emit_signal("connected_to_stream") + + var path := "%s/v1/webhooks/stream/%s" % [path_root, api_key.uri_encode()] + var headers := PackedStringArray([ + "Accept: text/event-stream", + "Cache-Control: no-cache", + ]) + if not _last_event_id.is_empty(): + headers.append("Last-Event-ID: %s" % _last_event_id) + + var req_err := _client.request(HTTPClient.METHOD_GET, path, headers) + if req_err != OK: + return false + + while _client.get_status() == HTTPClient.STATUS_REQUESTING: + _client.poll() + await get_tree().process_frame + + if _client.get_status() != HTTPClient.STATUS_BODY: + return false + + # kit's SSE handler returns 401 on bad/rotated apiKey, 412 on + # unconfigured platform, 5xx on transient backend issues. Without + # inspecting the response code, the body-reader loop below would + # consume the error JSON, _drain_frames would find no SSE frame, + # and _open_and_drain would return true — _run_loop would then + # silently reconnect forever with zero user-visible feedback. Bail + # loudly with HTTP_ERROR so the caller can surface a real error. + # Accept any 2xx (200-299) — kit returns 200 today but the SSE spec + # and common proxy paths permit 201/202/204 success codes too. The + # 4xx-vs-5xx terminal split below is what we actually care about + # (PR #124 (https://github.com/hyodotdev/openiap/pull/124) review). + var response_code := _client.get_response_code() + if response_code < 200 or response_code >= 300: + emit_signal("stream_error", "HTTP_ERROR", "Unexpected HTTP response: %d" % response_code) + # 4xx responses (401 INVALID_API_KEY, 412 *_NOT_CONFIGURED) will + # never succeed on retry — stop the loop so the operator sees + # the error instead of an infinite log spam. 5xx is transient + # and should reconnect on the normal back-off. + if response_code >= 400 and response_code < 500: + _running = false + return false + + _buffer = PackedByteArray() + while _client.get_status() == HTTPClient.STATUS_BODY and _running: + _client.poll() + var chunk: PackedByteArray = _client.read_response_body_chunk() + if chunk.size() > 0: + _buffer.append_array(chunk) + _drain_frames() + else: + await get_tree().process_frame + + return true + +func _drain_frames() -> void: + # SSE frames are terminated by a blank line ("\n\n" or "\r\n\r\n"). + # Operate on the byte buffer so that a UTF-8 codepoint split across + # two chunks is preserved until its trailing bytes arrive — the + # previous String-based buffer would have lost the head bytes when + # `get_string_from_utf8()` returned empty on an incomplete tail. + while true: + var boundary := _find_frame_boundary(_buffer) + if boundary.idx < 0: + return + var frame_bytes := _buffer.slice(0, boundary.idx) + _buffer = _buffer.slice(boundary.idx + boundary.sep_len) + var frame := frame_bytes.get_string_from_utf8() + _process_frame(frame) + +# Returns {idx, sep_len} where idx is the byte offset of the first +# blank-line separator in the buffer, and sep_len is the byte length +# of that separator. Returns idx = -1 when no complete frame has +# arrived. Per the SSE spec (whatwg, section "Interpreting an event +# stream"), a line terminator is *any* of CRLF, LF, or CR — and a +# blank line is two consecutive terminators in any combination. The +# byte scan below accepts every combination so we don't miss frames +# emitted by servers using CR-only or mixed terminators. +func _find_frame_boundary(buf: PackedByteArray) -> Dictionary: + var n := buf.size() + var i := 0 + while i < n: + # Length of the line terminator starting at index i (0 if not + # a terminator). Accept CRLF (2), LF (1), CR (1). + var first_len := _terminator_length(buf, i, n) + if first_len == 0: + i += 1 + continue + var second_len := _terminator_length(buf, i + first_len, n) + if second_len == 0: + i += first_len + continue + return { "idx": i, "sep_len": first_len + second_len } + return { "idx": -1, "sep_len": 0 } + +# Length (1 or 2) of the line terminator starting at `idx`, or 0 if +# the byte at `idx` isn't a terminator. CRLF takes precedence so +# `\r\n` is reported as 2, not as 1+1. +func _terminator_length(buf: PackedByteArray, idx: int, n: int) -> int: + if idx >= n: + return 0 + var b := buf[idx] + if b == 0x0D and idx + 1 < n and buf[idx + 1] == 0x0A: + return 2 + if b == 0x0A or b == 0x0D: + return 1 + return 0 + +func _process_frame(frame: String) -> void: + if frame.is_empty(): + return + var event_name := "" + var event_id := "" + var data_lines: Array[String] = [] + # WHATWG SSE spec accepts CR, LF, or CRLF as a line terminator. + # Normalize to "\n" first so split + ends_with(":") downstream + # behave correctly even on CR-only servers (rare but spec-allowed). + var normalized := frame.replace("\r\n", "\n").replace("\r", "\n") + for line in normalized.split("\n", false): + var stripped := line + if stripped.begins_with(":"): + continue # SSE comment + var colon := stripped.find(":") + if colon < 0: + continue + var field := stripped.substr(0, colon) + var value := stripped.substr(colon + 1) + if value.begins_with(" "): + value = value.substr(1) + match field: + "event": + event_name = value + "id": + event_id = value + "data": + data_lines.append(value) + # Don't advance `_last_event_id` here — wait until we've actually + # emitted the parsed event below. If parsing fails (PARSE_ERROR / + # MALFORMED_EVENT) we'd otherwise move the reconnect cursor past + # an event the listener never received, so the next connection + # would skip it permanently. + if data_lines.is_empty(): + return + if event_name == "heartbeat" or event_name == "ready": + return + var data_str := "\n".join(data_lines) + if data_str.is_empty(): + return + var json := JSON.new() + var err := json.parse(data_str) + if err != OK: + emit_signal("stream_error", "PARSE_ERROR", "Failed to parse SSE payload: %s" % json.get_error_message()) + return + var decoded = json.data + if typeof(decoded) != TYPE_DICTIONARY: + return + # `has(...)` returns true even for explicit-null fields, so an + # upstream payload like `{"id": null, ...}` would slip through and + # downstream listeners would see a partial event. Reject any + # required field that is missing OR null. + for required in ["id", "type"]: + if not _is_non_empty_string(decoded.get(required)): + emit_signal("stream_error", "MALFORMED_EVENT", "WebhookEvent missing required fields") + return + # purchaseToken is required for every event type *except* + # TestNotification — Apple ASN v2 / Google RTDN test payloads + # carry no transaction. Hard-rejecting here would surface valid + # test webhooks as MALFORMED_EVENT and never reach listeners. + if decoded["type"] != "TestNotification": + if not _is_non_empty_string(decoded.get("purchaseToken")): + emit_signal("stream_error", "MALFORMED_EVENT", "WebhookEvent missing required field purchaseToken") + return + emit_signal("event_received", decoded) + # Cursor advances only after a successful emit. + if not event_id.is_empty(): + _last_event_id = event_id + + +# True when value is a non-empty string. Used to reject upstream +# payloads where required fields (id, type, purchaseToken) come back +# as numeric / null / empty — without this, a malformed event with +# `{"id": 0, "type": "..."}` would silently flow through to listeners +# and crash on `String(event.id)`-shaped consumers. +func _is_non_empty_string(value) -> bool: + if value == null: + return false + if typeof(value) != TYPE_STRING: + return false + return not String(value).is_empty() diff --git a/libraries/kmp-iap/library/build.gradle.kts b/libraries/kmp-iap/library/build.gradle.kts index 51b71cc6..21de5b0d 100644 --- a/libraries/kmp-iap/library/build.gradle.kts +++ b/libraries/kmp-iap/library/build.gradle.kts @@ -116,6 +116,17 @@ group = "io.github.hyochan" version = project.findProperty("libraryVersion")?.toString() ?: "1.0.0-alpha02" kotlin { + // openiap WebhookTransport is shipped as `expect class` in + // commonMain with platform-specific actual implementations in + // androidMain / iosMain. Kotlin 2.x emits a warning for this + // pattern unless the `-Xexpect-actual-classes` flag is set; + // applying it here keeps the build clean for the kmp-iap + // consumers without surfacing warnings. + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + androidTarget { publishLibraryVariants("release") @OptIn(ExperimentalKotlinGradlePluginApi::class) diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.android.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.android.kt new file mode 100644 index 00000000..9d9c4a91 --- /dev/null +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.android.kt @@ -0,0 +1,395 @@ +package io.github.hyochan.kmpiap.openiap + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.job +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.atomic.AtomicBoolean + +/** + * androidMain SSE transport. Built on `HttpURLConnection` rather than + * OkHttp so the module ships without an extra runtime dep — every + * Android API level since 21 has a robust HUC implementation, and + * Convex / Apple / Google's SSE responses use chunked transfer with + * plain UTF-8 text which HUC handles fine. + * + * Reconnect strategy: collectors get an indefinite stream that + * reconnects with `Last-Event-ID` after a 2-second delay on transport + * errors. The collector cancels the underlying read by closing the + * scope. + */ +actual class WebhookTransport actual constructor( + private val apiKey: String, + private val baseUrl: String, +) { + @Volatile private var closed: Boolean = false + // Track every in-flight connection so close() can disconnect ALL + // of them — the prior single-slot `activeConnection` was overwritten + // when a second collector subscribed, and close() then leaked the + // first collector's socket. Synchronized via the connections set's + // own monitor. + private val activeConnections = mutableSetOf() + // Single-collector enforcement guard. The transport contract + // (commonMain WebhookTransport.kt) is one-collector-per-instance, + // and concurrent collectors would step on each other's + // Last-Event-ID cursor + double-process the same backlog. + // AtomicBoolean so the check + set is race-free under concurrent + // events() calls. + private val collecting = AtomicBoolean(false) + + actual fun events(lastEventId: String?): Flow = flow { + if (!collecting.compareAndSet(false, true)) { + throw IllegalStateException( + "WebhookTransport.events() is single-collector — close the existing collection first.", + ) + } + // Per-collector connection-cleanup hook. When the collector's + // coroutine cancels (job.cancel()), invoke disconnect() on + // whatever connection is currently active so the blocking + // input.read() unblocks via SocketException instead of waiting + // out the 60s read timeout. + var localConnection: HttpURLConnection? = null + val cancelHandle = currentCoroutineContext().job.invokeOnCompletion { + runCatching { localConnection?.disconnect() } + } + var resumeId: String? = lastEventId + try { + while (!closed) { + val url = URL(webhookStreamUrl(baseUrl, apiKey)) + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + setRequestProperty("Accept", "text/event-stream") + setRequestProperty("Cache-Control", "no-cache") + if (resumeId != null) { + setRequestProperty("Last-Event-ID", resumeId) + } + connectTimeout = 30_000 + // 60s read timeout — long enough for the kit's 25s + // heartbeat to keep the connection alive under + // healthy conditions, but tight enough that a half- + // open TCP state (NAT timeout, dropped Wi-Fi) trips a + // SocketTimeoutException quickly so the reconnect + // back-off can kick in. The previous value of 0 + // disabled the timeout entirely, which left dead + // connections wedged until the OS cleared them. + readTimeout = 60_000 + doInput = true + } + localConnection = connection + synchronized(activeConnections) { activeConnections.add(connection) } + try { + connection.connect() + if (connection.responseCode !in 200..299) { + val code = connection.responseCode + // Treat only the *permanent* 4xx codes as terminal: + // - 401 INVALID_API_KEY + // - 403 FORBIDDEN + // - 404 NOT_FOUND + // - 410 GONE + // - 412 *_NOT_CONFIGURED + // - 422 UNPROCESSABLE_ENTITY + // + // 408 Request Timeout, 425 Too Early, and 429 Too + // Many Requests are *transient* and will succeed on + // a back-off retry; the previous "any 4xx is + // terminal" check permanently disabled reconnects + // for them (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) + // review). 5xx falls through to the normal back-off. + val isTerminal = code == HttpURLConnection.HTTP_UNAUTHORIZED || + code == HttpURLConnection.HTTP_FORBIDDEN || + code == HttpURLConnection.HTTP_NOT_FOUND || + code == HttpURLConnection.HTTP_GONE || + code == HttpURLConnection.HTTP_PRECON_FAILED || + code == 422 + if (isTerminal) { + closed = true + } + throw IllegalStateException( + "SSE connect returned $code", + ) + } + // Byte-level frame buffer. WHATWG SSE accepts CR, LF, + // or CRLF as a line terminator, and a frame separator + // is any two consecutive terminators (including mixed + // forms like \r\n\r, \r\r, \n\r\n). The previous + // BufferedReader.readLine() approach was platform-line- + // ending dependent and silently dropped frames from + // CR-only servers. Operating on raw bytes here matches + // the iOS / Flutter / Godot transports. + val input: InputStream = connection.inputStream + val buf = ByteArray(8 * 1024) + val pending = ByteArrayBuilder() + while (!closed) { + // Coop-cancellation check before each blocking + // read. Without this the loop only notices + // cancellation at emit() / delay() — a heartbeat- + // only stream could sit inside input.read() for + // up to readTimeout (60s) after the collector + // cancelled. The invokeOnCompletion above also + // disconnects the underlying socket, which forces + // the read to throw, but ensureActive() makes the + // coroutine cooperate as soon as it has a + // suspension opportunity. + currentCoroutineContext().ensureActive() + val n = input.read(buf) + if (n == -1) break + if (n == 0) continue + pending.append(buf, 0, n) + while (true) { + currentCoroutineContext().ensureActive() + val boundary = + findFirstFrameBoundary(pending.bytes, pending.size) + if (boundary == null) break + // boundary.end = byte offset just past the + // trailing terminator pair; bodyLen = bytes + // before that pair (the parseable body). + val bodyLen = boundary.end - boundary.pairLength + val body = String( + pending.bytes, + 0, + bodyLen, + Charsets.UTF_8, + ) + pending.dropFirst(boundary.end) + val parsed = parseSseFrame(body) + // Cursor advancement rules: + // - Successful parse + emit → advance, so a + // reconnect doesn't redeliver an event the + // consumer already saw. + // - Parse failed AND the frame carried an + // eventId → still advance (poison-pill + // prevention). + // - Heartbeat / ready / parse-null without + // an eventId → don't touch resumeId. + val event = parsed.event + if (event != null) { + // Wrap emit so a downstream collector + // exception isn't accidentally caught by + // the outer transport-error handler — a + // consumer that throws should propagate + // upstream as-is, not silently trigger a + // reconnect that risks redelivering the + // same event to a now-broken collector. + try { + emit(event) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (consumerError: Throwable) { + throw EmitError(consumerError) + } + parsed.eventId?.let { resumeId = it } + } else if (parsed.shouldAdvanceCursorOnDrop) { + parsed.eventId?.let { resumeId = it } + } + } + } + } catch (cancellation: CancellationException) { + // Coroutine cancellation must propagate so the + // collector can tear down — wrapping it in the + // generic Throwable catch below would treat the + // cancellation as a transient transport error and + // re-enter the retry loop. Re-throw before any + // back-off / reconnect logic runs. + throw cancellation + } catch (emitError: EmitError) { + // Downstream collector failed — surface the original + // exception to the caller instead of treating it as a + // transient transport error and reconnecting. + throw emitError.cause ?: emitError + } catch (error: Throwable) { + if (closed) break + // Check coroutine cancellation explicitly. When the + // collector's job is cancelled, the disconnect() in + // invokeOnCompletion fires SocketException through + // input.read(), which lands here as a generic + // Throwable. Without this check we'd fall through to + // delay(2_000) and keep the transport alive an extra + // 2s after cancellation (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) + // review). + if (!currentCoroutineContext().isActive) break + // fall through to the back-off + reconnect. + } finally { + runCatching { connection.disconnect() } + synchronized(activeConnections) { + activeConnections.remove(connection) + } + if (localConnection === connection) { + localConnection = null + } + } + if (closed) break + delay(2_000) + } + } finally { + // Always release the cancel handle + collector slot, even + // if the loop exits via an unexpected throw. Without + // releasing collecting, a follow-up events() call on the + // same transport would never get past the AtomicBoolean + // guard. + cancelHandle.dispose() + collecting.set(false) + } + }.flowOn(Dispatchers.IO) + + actual fun close() { + closed = true + // Disconnect every in-flight connection. Multiple collectors + // can share the same transport instance during transient + // teardown windows (old collector still draining, new one + // booting), and the prior single-slot field overwrote the + // first connection on the second collector's create — leaking + // the first socket until OS GC. Snapshot under the monitor so + // a concurrent insert in events() can't race with the iterate. + val snapshot: List = + synchronized(activeConnections) { activeConnections.toList() } + for (conn in snapshot) { + runCatching { conn.disconnect() } + } + } +} + +private data class ParsedSseFrame( + val eventId: String?, + val eventType: String?, + val event: WebhookEvent?, + // True when the frame carried an eventId AND parsing failed, + // signaling the caller to advance the reconnect cursor anyway so + // the malformed payload doesn't block all future events behind a + // poison-pill replay. + val shouldAdvanceCursorOnDrop: Boolean = false, +) + +private val SSE_LINE_SEPARATOR = Regex("\\r\\n|\\r|\\n") + +// Sentinel wrapper for exceptions thrown by the downstream collector +// (`emit(event)`). The transport-error catch clause treats network +// I/O failures as transient and reconnects, but a consumer-side +// exception should propagate to the caller as-is. +private class EmitError(cause: Throwable) : Throwable(cause) + +private fun parseSseFrame(frame: String): ParsedSseFrame { + if (frame.isEmpty()) return ParsedSseFrame(null, null, null) + var eventId: String? = null + var eventType: String? = null + val data = StringBuilder() + // Per WHATWG SSE spec, a line within a frame can be terminated + // by CR, LF, or CRLF. Splitting only on '\n' would mis-parse + // CR-only servers (rare but spec-allowed) — fields like `id` and + // `event` would silently merge into one giant `id` value. + for (rawLine in frame.split(SSE_LINE_SEPARATOR)) { + val stripped = rawLine + if (stripped.startsWith(":")) continue // comment + val colon = stripped.indexOf(':') + if (colon < 0) continue + val field = stripped.substring(0, colon) + var value = stripped.substring(colon + 1) + if (value.startsWith(" ")) value = value.substring(1) + when (field) { + "id" -> eventId = value + "event" -> eventType = value + "data" -> { + if (data.isNotEmpty()) data.append('\n') + data.append(value) + } + } + } + if (eventType == "heartbeat" || eventType == "ready" || data.isEmpty()) { + return ParsedSseFrame(eventId, eventType, null) + } + val event = WebhookEventParser.parse(data.toString()) + return ParsedSseFrame( + eventId = eventId, + eventType = eventType, + event = event, + shouldAdvanceCursorOnDrop = event == null && eventId != null, + ) +} + +// Minimal growable byte buffer. Avoids the per-byte boxing of +// ArrayDeque for SSE traffic that can run hundreds of KB/s +// during backlog drains. +private class ByteArrayBuilder(initialCapacity: Int = 16 * 1024) { + var bytes: ByteArray = ByteArray(initialCapacity) + private set + var size: Int = 0 + private set + + fun append(src: ByteArray, offset: Int, len: Int) { + ensureCapacity(size + len) + System.arraycopy(src, offset, bytes, size, len) + size += len + } + + /** + * Drops the first [count] bytes; the remaining tail shifts down + * to offset 0. No allocation. + */ + fun dropFirst(count: Int) { + if (count >= size) { + size = 0 + return + } + System.arraycopy(bytes, count, bytes, 0, size - count) + size -= count + } + + private fun ensureCapacity(min: Int) { + if (bytes.size >= min) return + var next = bytes.size * 2 + while (next < min) next *= 2 + bytes = bytes.copyOf(next) + } +} + +// Length (1 or 2) of the line terminator starting at [idx], or 0 if +// the byte at [idx] isn't a terminator. CRLF takes precedence so +// `\r\n` is reported as 2, not 1+1. Per WHATWG SSE spec. +private fun terminatorLength(buf: ByteArray, idx: Int, end: Int): Int { + if (idx >= end) return 0 + val b = buf[idx] + if (b == 0x0D.toByte() && idx + 1 < end && buf[idx + 1] == 0x0A.toByte()) { + return 2 + } + if (b == 0x0A.toByte() || b == 0x0D.toByte()) return 1 + return 0 +} + +// Result of a successful frame-boundary scan. `end` is the byte +// offset just past the trailing terminator pair; `pairLength` is the +// total length of that pair (sum of both terminator lengths) so the +// caller can subtract it to get the parseable body length. +private data class FrameBoundary(val end: Int, val pairLength: Int) + +// Returns the FIRST complete frame separator (two consecutive line +// terminators) within [0, length), or null when no complete frame +// has arrived yet. Operates on raw bytes so a multi-byte UTF-8 +// character in the tail can never produce a false match — 0x0A and +// 0x0D never appear inside the body of a multi-byte UTF-8 codepoint. +private fun findFirstFrameBoundary(buf: ByteArray, length: Int): FrameBoundary? { + var i = 0 + while (i < length) { + val firstLen = terminatorLength(buf, i, length) + if (firstLen == 0) { + i += 1 + continue + } + val secondLen = terminatorLength(buf, i + firstLen, length) + if (secondLen == 0) { + i += firstLen + continue + } + return FrameBoundary(end = i + firstLen + secondLen, pairLength = firstLen + secondLen) + } + return null +} diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt index 2c749a69..b63e3a5b 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt @@ -1053,6 +1053,295 @@ public enum class SubscriptionReplacementModeAndroid(val rawValue: String) { fun toJson(): String = rawValue } +public enum class SubscriptionState(val rawValue: String) { + Active("active"), + InGracePeriod("in-grace-period"), + InBillingRetry("in-billing-retry"), + Expired("expired"), + Revoked("revoked"), + Refunded("refunded"), + Paused("paused"), + Unknown("unknown"); + + companion object { + fun fromJson(value: String): SubscriptionState = when (value) { + "active" -> SubscriptionState.Active + "ACTIVE" -> SubscriptionState.Active + "Active" -> SubscriptionState.Active + "in-grace-period" -> SubscriptionState.InGracePeriod + "IN_GRACE_PERIOD" -> SubscriptionState.InGracePeriod + "InGracePeriod" -> SubscriptionState.InGracePeriod + "in-billing-retry" -> SubscriptionState.InBillingRetry + "IN_BILLING_RETRY" -> SubscriptionState.InBillingRetry + "InBillingRetry" -> SubscriptionState.InBillingRetry + "expired" -> SubscriptionState.Expired + "EXPIRED" -> SubscriptionState.Expired + "Expired" -> SubscriptionState.Expired + "revoked" -> SubscriptionState.Revoked + "REVOKED" -> SubscriptionState.Revoked + "Revoked" -> SubscriptionState.Revoked + "refunded" -> SubscriptionState.Refunded + "REFUNDED" -> SubscriptionState.Refunded + "Refunded" -> SubscriptionState.Refunded + "paused" -> SubscriptionState.Paused + "PAUSED" -> SubscriptionState.Paused + "Paused" -> SubscriptionState.Paused + "unknown" -> SubscriptionState.Unknown + "UNKNOWN" -> SubscriptionState.Unknown + "Unknown" -> SubscriptionState.Unknown + else -> throw IllegalArgumentException("Unknown SubscriptionState value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookCancellationReason(val rawValue: String) { + UserCanceled("user-canceled"), + BillingError("billing-error"), + PriceIncreaseDeclined("price-increase-declined"), + ProductUnavailable("product-unavailable"), + Refunded("refunded"), + Other("other"); + + companion object { + fun fromJson(value: String): WebhookCancellationReason = when (value) { + "user-canceled" -> WebhookCancellationReason.UserCanceled + "USER_CANCELED" -> WebhookCancellationReason.UserCanceled + "UserCanceled" -> WebhookCancellationReason.UserCanceled + "billing-error" -> WebhookCancellationReason.BillingError + "BILLING_ERROR" -> WebhookCancellationReason.BillingError + "BillingError" -> WebhookCancellationReason.BillingError + "price-increase-declined" -> WebhookCancellationReason.PriceIncreaseDeclined + "PRICE_INCREASE_DECLINED" -> WebhookCancellationReason.PriceIncreaseDeclined + "PriceIncreaseDeclined" -> WebhookCancellationReason.PriceIncreaseDeclined + "product-unavailable" -> WebhookCancellationReason.ProductUnavailable + "PRODUCT_UNAVAILABLE" -> WebhookCancellationReason.ProductUnavailable + "ProductUnavailable" -> WebhookCancellationReason.ProductUnavailable + "refunded" -> WebhookCancellationReason.Refunded + "REFUNDED" -> WebhookCancellationReason.Refunded + "Refunded" -> WebhookCancellationReason.Refunded + "other" -> WebhookCancellationReason.Other + "OTHER" -> WebhookCancellationReason.Other + "Other" -> WebhookCancellationReason.Other + else -> throw IllegalArgumentException("Unknown WebhookCancellationReason value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventEnvironment(val rawValue: String) { + Production("production"), + Sandbox("sandbox"), + Xcode("xcode"); + + companion object { + fun fromJson(value: String): WebhookEventEnvironment = when (value) { + "production" -> WebhookEventEnvironment.Production + "PRODUCTION" -> WebhookEventEnvironment.Production + "Production" -> WebhookEventEnvironment.Production + "sandbox" -> WebhookEventEnvironment.Sandbox + "SANDBOX" -> WebhookEventEnvironment.Sandbox + "Sandbox" -> WebhookEventEnvironment.Sandbox + "xcode" -> WebhookEventEnvironment.Xcode + "XCODE" -> WebhookEventEnvironment.Xcode + "Xcode" -> WebhookEventEnvironment.Xcode + else -> throw IllegalArgumentException("Unknown WebhookEventEnvironment value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventSource(val rawValue: String) { + AppleAppStoreServerNotificationsV2("apple-app-store-server-notifications-v2"), + GooglePlayRealTimeDeveloperNotifications("google-play-real-time-developer-notifications"), + /** + * Synthetic source for Meta Horizon Store. Meta has no webhook / + * push notification system so kit polls `verify_entitlement` on a + * cron and emits these synthetic events when an entitlement + * transitions. SDK consumers see them on the SSE stream alongside + * real Apple / Google webhooks. + */ + MetaHorizonReconciler("meta-horizon-reconciler"); + + companion object { + fun fromJson(value: String): WebhookEventSource = when (value) { + "apple-app-store-server-notifications-v2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "AppleAppStoreServerNotificationsV2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "google-play-real-time-developer-notifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "GooglePlayRealTimeDeveloperNotifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "meta-horizon-reconciler" -> WebhookEventSource.MetaHorizonReconciler + "META_HORIZON_RECONCILER" -> WebhookEventSource.MetaHorizonReconciler + "MetaHorizonReconciler" -> WebhookEventSource.MetaHorizonReconciler + else -> throw IllegalArgumentException("Unknown WebhookEventSource value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventType(val rawValue: String) { + /** + * Initial purchase or first conversion from a free trial / intro offer. + * iOS: SUBSCRIBED (initialBuy / resubscribe). + * Android: SUBSCRIPTION_PURCHASED. + */ + SubscriptionStarted("subscription-started"), + /** + * Auto-renewal succeeded for an existing subscription. + * iOS: DID_RENEW. + * Android: SUBSCRIPTION_RENEWED. + */ + SubscriptionRenewed("subscription-renewed"), + /** + * Subscription reached its expiration without a successful renewal. + * iOS: EXPIRED. + * Android: SUBSCRIPTION_EXPIRED. + */ + SubscriptionExpired("subscription-expired"), + /** + * Billing failed; the subscription is in a grace period during which the user + * retains entitlement while payment is retried. + * iOS: DID_FAIL_TO_RENEW (with grace period active). + * Android: SUBSCRIPTION_IN_GRACE_PERIOD. + */ + SubscriptionInGracePeriod("subscription-in-grace-period"), + /** + * Billing failed and the subscription is in account-hold / billing retry, + * during which entitlement is paused but the subscription is not yet expired. + * iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + * Android: SUBSCRIPTION_ON_HOLD. + */ + SubscriptionInBillingRetry("subscription-in-billing-retry"), + /** + * Subscription returned to active state after a billing issue or pause. + * iOS: DID_RECOVER. + * Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- + * renew re-enabled (Uncanceled), not billing recovery. + */ + SubscriptionRecovered("subscription-recovered"), + /** + * User turned off auto-renew. Access continues until the current period ends. + * iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). + * Android: SUBSCRIPTION_CANCELED. + */ + SubscriptionCanceled("subscription-canceled"), + /** + * User reactivated auto-renew before the subscription expired. + * iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). + * Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + */ + SubscriptionUncanceled("subscription-uncanceled"), + /** + * Access immediately revoked (family sharing removal, admin action, fraud). + * iOS: REVOKE. + * Android: SUBSCRIPTION_REVOKED. + */ + SubscriptionRevoked("subscription-revoked"), + /** + * A price change is pending or has been confirmed by the user. + * iOS: PRICE_INCREASE. + * Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + */ + SubscriptionPriceChange("subscription-price-change"), + /** + * User upgraded, downgraded, or crossgraded their plan. + * iOS: DID_CHANGE_RENEWAL_PREF. + * Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + */ + SubscriptionProductChanged("subscription-product-changed"), + /** + * Subscription paused (Android only feature). Also fired when the + * pause schedule is changed — RTDN does not have a separate signal. + * Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). + */ + SubscriptionPaused("subscription-paused"), + /** + * Paused subscription resumed (Android only feature). RTDN signals + * resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle + * starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the + * resume. + * Android: SUBSCRIPTION_RECOVERED (after pause). + */ + SubscriptionResumed("subscription-resumed"), + /** + * Refund issued for a one-time purchase or subscription period. + * iOS: REFUND. + * Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + */ + PurchaseRefunded("purchase-refunded"), + /** + * iOS-only: App Store requests a consumption status report for a refund decision. + * Servers should respond via the StoreKit consumption API. + */ + PurchaseConsumptionRequest("purchase-consumption-request"), + /** + * Sandbox or test notification fired by the store for diagnostic purposes. + * Useful for verifying webhook plumbing without a live transaction. + */ + TestNotification("test-notification"); + + companion object { + fun fromJson(value: String): WebhookEventType = when (value) { + "subscription-started" -> WebhookEventType.SubscriptionStarted + "SUBSCRIPTION_STARTED" -> WebhookEventType.SubscriptionStarted + "SubscriptionStarted" -> WebhookEventType.SubscriptionStarted + "subscription-renewed" -> WebhookEventType.SubscriptionRenewed + "SUBSCRIPTION_RENEWED" -> WebhookEventType.SubscriptionRenewed + "SubscriptionRenewed" -> WebhookEventType.SubscriptionRenewed + "subscription-expired" -> WebhookEventType.SubscriptionExpired + "SUBSCRIPTION_EXPIRED" -> WebhookEventType.SubscriptionExpired + "SubscriptionExpired" -> WebhookEventType.SubscriptionExpired + "subscription-in-grace-period" -> WebhookEventType.SubscriptionInGracePeriod + "SUBSCRIPTION_IN_GRACE_PERIOD" -> WebhookEventType.SubscriptionInGracePeriod + "SubscriptionInGracePeriod" -> WebhookEventType.SubscriptionInGracePeriod + "subscription-in-billing-retry" -> WebhookEventType.SubscriptionInBillingRetry + "SUBSCRIPTION_IN_BILLING_RETRY" -> WebhookEventType.SubscriptionInBillingRetry + "SubscriptionInBillingRetry" -> WebhookEventType.SubscriptionInBillingRetry + "subscription-recovered" -> WebhookEventType.SubscriptionRecovered + "SUBSCRIPTION_RECOVERED" -> WebhookEventType.SubscriptionRecovered + "SubscriptionRecovered" -> WebhookEventType.SubscriptionRecovered + "subscription-canceled" -> WebhookEventType.SubscriptionCanceled + "SUBSCRIPTION_CANCELED" -> WebhookEventType.SubscriptionCanceled + "SubscriptionCanceled" -> WebhookEventType.SubscriptionCanceled + "subscription-uncanceled" -> WebhookEventType.SubscriptionUncanceled + "SUBSCRIPTION_UNCANCELED" -> WebhookEventType.SubscriptionUncanceled + "SubscriptionUncanceled" -> WebhookEventType.SubscriptionUncanceled + "subscription-revoked" -> WebhookEventType.SubscriptionRevoked + "SUBSCRIPTION_REVOKED" -> WebhookEventType.SubscriptionRevoked + "SubscriptionRevoked" -> WebhookEventType.SubscriptionRevoked + "subscription-price-change" -> WebhookEventType.SubscriptionPriceChange + "SUBSCRIPTION_PRICE_CHANGE" -> WebhookEventType.SubscriptionPriceChange + "SubscriptionPriceChange" -> WebhookEventType.SubscriptionPriceChange + "subscription-product-changed" -> WebhookEventType.SubscriptionProductChanged + "SUBSCRIPTION_PRODUCT_CHANGED" -> WebhookEventType.SubscriptionProductChanged + "SubscriptionProductChanged" -> WebhookEventType.SubscriptionProductChanged + "subscription-paused" -> WebhookEventType.SubscriptionPaused + "SUBSCRIPTION_PAUSED" -> WebhookEventType.SubscriptionPaused + "SubscriptionPaused" -> WebhookEventType.SubscriptionPaused + "subscription-resumed" -> WebhookEventType.SubscriptionResumed + "SUBSCRIPTION_RESUMED" -> WebhookEventType.SubscriptionResumed + "SubscriptionResumed" -> WebhookEventType.SubscriptionResumed + "purchase-refunded" -> WebhookEventType.PurchaseRefunded + "PURCHASE_REFUNDED" -> WebhookEventType.PurchaseRefunded + "PurchaseRefunded" -> WebhookEventType.PurchaseRefunded + "purchase-consumption-request" -> WebhookEventType.PurchaseConsumptionRequest + "PURCHASE_CONSUMPTION_REQUEST" -> WebhookEventType.PurchaseConsumptionRequest + "PurchaseConsumptionRequest" -> WebhookEventType.PurchaseConsumptionRequest + "test-notification" -> WebhookEventType.TestNotification + "TEST_NOTIFICATION" -> WebhookEventType.TestNotification + "TestNotification" -> WebhookEventType.TestNotification + else -> throw IllegalArgumentException("Unknown WebhookEventType value: $value") + } + } + + fun toJson(): String = rawValue +} + // MARK: - Interfaces public interface ProductCommon { @@ -3678,6 +3967,121 @@ public data class VerifyPurchaseWithProviderResult( public typealias VoidResult = Unit +public data class WebhookEvent( + /** + * Reason for cancellation, when applicable. + */ + val cancellationReason: WebhookCancellationReason? = null, + /** + * Localized currency code (ISO 4217) at event time, when available. + */ + val currency: String? = null, + val environment: WebhookEventEnvironment, + /** + * When the current subscription period ends. Epoch milliseconds. + */ + val expiresAt: Double? = null, + /** + * Stable identifier suitable for idempotency. Derived from the source notification + * UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + * otherwise hashed from the canonicalized payload. + */ + val id: String, + /** + * Time the underlying event occurred at the store. Epoch milliseconds. + */ + val occurredAt: Double, + val platform: IapPlatform, + /** + * Price in micros (1/1,000,000 of the currency unit) at event time, when available. + * Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + */ + val priceAmountMicros: Double? = null, + /** + * Product the event pertains to. May be null for account-level events. + */ + val productId: String? = null, + /** + * kit project that owns the subscription / purchase this event refers to. + */ + val projectId: String, + /** + * Cross-platform purchase identity used to correlate this event with an existing + * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + * Null for `TestNotification` events (Apple ASN v2 / Google RTDN test + * payloads carry no transaction); always present for every other event type. + */ + val purchaseToken: String? = null, + /** + * Original signed payload from the store. ASN v2 events expose the JWS string; + * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + * consumers can independently verify or extract platform-specific fields. kit + * always validates this payload before emitting the event. + */ + val rawSignedPayload: String? = null, + /** + * Time kit ingested and normalized this event. Epoch milliseconds. + */ + val receivedAt: Double, + /** + * When auto-renewal will charge again. Epoch milliseconds. + */ + val renewsAt: Double? = null, + val source: WebhookEventSource, + /** + * Normalized subscription state at the time of event, when the event refers to + * a subscription. Null for one-time purchase events. + */ + val subscriptionState: SubscriptionState? = null, + val type: WebhookEventType +) { + + companion object { + fun fromJson(json: Map): WebhookEvent { + return WebhookEvent( + cancellationReason = (json["cancellationReason"] as? String)?.let { WebhookCancellationReason.fromJson(it) }, + currency = json["currency"] as? String, + environment = (json["environment"] as? String)?.let { WebhookEventEnvironment.fromJson(it) } ?: WebhookEventEnvironment.Production, + expiresAt = (json["expiresAt"] as? Number)?.toDouble(), + id = json["id"] as? String ?: "", + occurredAt = (json["occurredAt"] as? Number)?.toDouble() ?: 0.0, + platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios, + priceAmountMicros = (json["priceAmountMicros"] as? Number)?.toDouble(), + productId = json["productId"] as? String, + projectId = json["projectId"] as? String ?: "", + purchaseToken = json["purchaseToken"] as? String, + rawSignedPayload = json["rawSignedPayload"] as? String, + receivedAt = (json["receivedAt"] as? Number)?.toDouble() ?: 0.0, + renewsAt = (json["renewsAt"] as? Number)?.toDouble(), + source = (json["source"] as? String)?.let { WebhookEventSource.fromJson(it) } ?: WebhookEventSource.AppleAppStoreServerNotificationsV2, + subscriptionState = (json["subscriptionState"] as? String)?.let { SubscriptionState.fromJson(it) }, + type = (json["type"] as? String)?.let { WebhookEventType.fromJson(it) } ?: WebhookEventType.SubscriptionStarted, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "WebhookEvent", + "cancellationReason" to cancellationReason?.toJson(), + "currency" to currency, + "environment" to environment.toJson(), + "expiresAt" to expiresAt, + "id" to id, + "occurredAt" to occurredAt, + "platform" to platform.toJson(), + "priceAmountMicros" to priceAmountMicros, + "productId" to productId, + "projectId" to projectId, + "purchaseToken" to purchaseToken, + "rawSignedPayload" to rawSignedPayload, + "receivedAt" to receivedAt, + "renewsAt" to renewsAt, + "source" to source.toJson(), + "subscriptionState" to subscriptionState?.toJson(), + "type" to type.toJson(), + ) +} + // MARK: - Input Objects public data class AndroidSubscriptionOfferInput( diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClient.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClient.kt new file mode 100644 index 00000000..3eb285de --- /dev/null +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClient.kt @@ -0,0 +1,173 @@ +package io.github.hyochan.kmpiap.openiap + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull + +/** + * Pure parser for the openiap kit SSE webhook stream + * (`GET /v1/webhooks/stream/{apiKey}`). + * + * The `WebhookEvent` data class + every enum used here come from the + * generated `Types.kt` (synced from `packages/gql/src/webhook.graphql`). + * This file only adds the SSE-frame → `WebhookEvent` parser and the + * URL builder. The transport (an actual HTTP+SSE client) lives in + * the per-target source sets — `androidMain/WebhookTransport.android.kt`, + * `iosMain/WebhookTransport.ios.kt` — because KMP doesn't ship a + * stdlib HTTP client and we don't want to pull in Ktor. + */ + +object WebhookEventParser { + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + /** + * Parse one SSE `data:` frame (a single JSON object) into a + * [WebhookEvent], or null if the frame is a heartbeat / control + * envelope / malformed payload. + */ + fun parse(rawJson: String): WebhookEvent? { + if (rawJson.isEmpty()) return null + val element: JsonElement = try { + json.parseToJsonElement(rawJson) + } catch (_: Throwable) { + return null + } + if (element !is JsonObject) return null + return fromJson(element) + } + + fun fromJson(element: JsonObject): WebhookEvent? { + return try { + // `contentOrNull` returns the raw JSON string value; it + // doesn't reject empty strings. Required string fields + // (id, type, environment, platform, source, projectId, + // and non-test purchaseToken) must additionally be + // non-blank, otherwise a malformed frame with `"id": ""` + // would deserialize into a partial WebhookEvent that + // crashes downstream consumers expecting non-empty ids. + val id = element["id"]?.jsonPrimitive?.nonBlankContentOrNull() + ?: return null + val typeRaw = + element["type"]?.jsonPrimitive?.nonBlankContentOrNull() + ?: return null + val purchaseToken = + element["purchaseToken"]?.jsonPrimitive?.contentOrNull + // purchaseToken is required for every event type *except* + // TestNotification — Apple ASN v2 / Google RTDN test + // payloads carry no transaction. Hard-rejecting here would + // surface valid test webhooks as null events and the SSE + // listener would never deliver them. Empty strings count + // as missing for the same reason as the other required + // fields above. + if (purchaseToken.isNullOrBlank() && typeRaw != "TestNotification") { + return null + } + val occurredAt = + element["occurredAt"]?.jsonPrimitive?.numericOrNull() + ?: return null + val receivedAt = + element["receivedAt"]?.jsonPrimitive?.numericOrNull() + ?: return null + val environmentRaw = + element["environment"]?.jsonPrimitive?.nonBlankContentOrNull() + ?: return null + val platformRaw = + element["platform"]?.jsonPrimitive?.nonBlankContentOrNull() + ?: return null + val sourceRaw = + element["source"]?.jsonPrimitive?.nonBlankContentOrNull() + ?: return null + + // The generated `fromJson` companion factories throw on + // unknown enum values. We catch the throw at the outer + // level (PR #123 (https://github.com/hyodotdev/openiap/pull/123) review: prefer fail-fast over silently + // mapping unknown types to a synthetic `Unknown` value). + WebhookEvent( + cancellationReason = + element["cancellationReason"]?.jsonPrimitive?.contentOrNull?.let { + WebhookCancellationReason.fromJson(it) + }, + currency = element["currency"]?.jsonPrimitive?.contentOrNull, + environment = WebhookEventEnvironment.fromJson(environmentRaw), + expiresAt = element["expiresAt"]?.jsonPrimitive?.numericOrNull(), + id = id, + occurredAt = occurredAt, + platform = IapPlatform.fromJson(platformRaw), + priceAmountMicros = + element["priceAmountMicros"]?.jsonPrimitive?.numericOrNull(), + productId = element["productId"]?.jsonPrimitive?.contentOrNull, + projectId = + element["projectId"]?.jsonPrimitive?.nonBlankContentOrNull() + ?: return null, + purchaseToken = purchaseToken, + rawSignedPayload = + element["rawSignedPayload"]?.jsonPrimitive?.contentOrNull, + receivedAt = receivedAt, + renewsAt = element["renewsAt"]?.jsonPrimitive?.numericOrNull(), + source = WebhookEventSource.fromJson(sourceRaw), + subscriptionState = + element["subscriptionState"]?.jsonPrimitive?.contentOrNull?.let { + SubscriptionState.fromJson(it) + }, + type = WebhookEventType.fromJson(typeRaw), + ) + } catch (_: Throwable) { + // Fail-fast → null lets the SSE listener surface + // MALFORMED_EVENT instead of emitting a half-decoded event. + null + } + } +} + +private fun JsonPrimitive.numericOrNull(): Double? = + content.toDoubleOrNull() ?: longOrNull?.toDouble() ?: intOrNull?.toDouble() + +/** + * Endpoint URL for the kit SSE stream. Kept on the type so callers + * don't reimplement the path layout in each transport. + */ +fun webhookStreamUrl(baseUrl: String = "https://kit.openiap.dev", apiKey: String): String { + val trimmed = if (baseUrl.endsWith("/")) baseUrl.dropLast(1) else baseUrl + // URL-encode the apiKey path segment. kit's apiKey format + // (`openiap-kit_`) doesn't currently include reserved + // characters, but a future format change or a misconfigured + // operator-supplied key could break routing without this guard. + // Matches the JS / Dart / GDScript clients which all + // encodeURIComponent the same segment. + return "$trimmed/v1/webhooks/stream/${encodePathSegment(apiKey)}" +} + +private fun encodePathSegment(value: String): String { + val sb = StringBuilder(value.length) + for (ch in value) { + if (ch.isLetterOrDigit() || ch == '-' || ch == '.' || ch == '_' || ch == '~') { + sb.append(ch) + } else { + // RFC 3986 percent-encoding for the unreserved set — + // matches Java's URLEncoder for path segments minus the + // legacy `+` substitution for spaces (we want %20 for + // path-segment use, not form-encoded space). + for (b in ch.toString().encodeToByteArray()) { + sb.append('%') + sb.append(((b.toInt() shr 4) and 0xF).toString(16).uppercase()) + sb.append((b.toInt() and 0xF).toString(16).uppercase()) + } + } + } + return sb.toString() +} + +// `JsonPrimitive.contentOrNull` returns the raw string value (or null +// for explicit JSON `null`). For required string fields we additionally +// want to reject empty / whitespace-only values that semantically count +// as missing. Returns null in that case so the parser's `?: return null` +// pattern naturally fails the frame as MALFORMED. +private fun JsonPrimitive.nonBlankContentOrNull(): String? { + val raw = contentOrNull ?: return null + return raw.takeIf { it.isNotBlank() } +} diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.kt new file mode 100644 index 00000000..1bc743cd --- /dev/null +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.kt @@ -0,0 +1,45 @@ +package io.github.hyochan.kmpiap.openiap + +import kotlinx.coroutines.flow.Flow + +/** + * Per-target SSE transport for the openiap kit webhook stream. The + * common surface is a Flow driven by an internal SSE + * reader; concrete transports live in androidMain / iosMain / jvmMain + * to plug in the platform's HTTP client (HttpURLConnection on Android + * and JVM, NSURLSession via cinterop for iOS). + * + * Reconnect: implementations should resubscribe on transport errors + * with a 2-second back-off, honoring the optional `lastEventId` the + * caller saved on the previous emission. The Flow surface itself is + * cold — collecting starts the connection, cancelling the collector + * tears it down. + */ +expect class WebhookTransport( + apiKey: String, + baseUrl: String = "https://kit.openiap.dev", +) { + /** + * Cold flow that emits one [WebhookEvent] per SSE `data:` frame. + * Subscribers may pass the `id` of the last received event into + * [lastEventId] on a subsequent invocation to resume from there. + */ + fun events(lastEventId: String? = null): Flow + + /** + * Releases any underlying connection resources owned by this + * transport instance. Calling [events] after [close] returns an + * empty flow. + */ + fun close() +} + +/** + * Convenience factory so call sites read like the JS / Dart APIs: + * + * val flow = connectWebhookStream(apiKey = "...").events() + */ +fun connectWebhookStream( + apiKey: String, + baseUrl: String = "https://kit.openiap.dev", +): WebhookTransport = WebhookTransport(apiKey, baseUrl) diff --git a/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClientTest.kt b/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClientTest.kt new file mode 100644 index 00000000..7566b699 --- /dev/null +++ b/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClientTest.kt @@ -0,0 +1,76 @@ +package io.github.hyochan.kmpiap.openiap + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class WebhookClientTest { + @Test + fun parsesACompleteEventPayload() { + val raw = """ + { + "id": "uuid-1", + "type": "SubscriptionRenewed", + "source": "AppleAppStoreServerNotificationsV2", + "platform": "IOS", + "environment": "Production", + "projectId": "p-1", + "occurredAt": 1711000000000, + "receivedAt": 1711000001000, + "purchaseToken": "token-1", + "productId": "com.example.premium", + "subscriptionState": "Active" + } + """.trimIndent() + + val event = WebhookEventParser.parse(raw) + assertNotNull(event) + assertEquals("uuid-1", event.id) + assertEquals(WebhookEventType.SubscriptionRenewed, event.type) + assertEquals("token-1", event.purchaseToken) + assertEquals("com.example.premium", event.productId) + assertEquals(1_711_000_000_000.0, event.occurredAt) + } + + @Test + fun returnsNullForEmptyOrMalformedInput() { + assertNull(WebhookEventParser.parse("")) + assertNull(WebhookEventParser.parse("not json")) + // Required fields missing → fail-fast (PR #123 (https://github.com/hyodotdev/openiap/pull/123) review fix: + // we no longer silently default to empty strings). + assertNull( + WebhookEventParser.parse("""{"type":"SubscriptionRenewed"}"""), + ) + } + + @Test + fun returnsNullForUnseenEventTypes() { + // Unknown event types are now rejected rather than mapped to a + // synthetic `Unknown` enum value — PR #123 (https://github.com/hyodotdev/openiap/pull/123) review correctly + // flagged that lenient parsing hides spec drift between kit + // and the SDK consumers. + val raw = """ + { + "id": "uuid-2", + "type": "SomethingNew", + "source": "AppleAppStoreServerNotificationsV2", + "platform": "IOS", + "environment": "Production", + "projectId": "p-1", + "occurredAt": 1, + "receivedAt": 2, + "purchaseToken": "t" + } + """.trimIndent() + assertNull(WebhookEventParser.parse(raw)) + } + + @Test + fun streamUrlBuilderTrimsTrailingSlashes() { + assertEquals( + "https://kit.openiap.dev/v1/webhooks/stream/key", + webhookStreamUrl(baseUrl = "https://kit.openiap.dev/", apiKey = "key"), + ) + } +} diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.ios.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.ios.kt new file mode 100644 index 00000000..825f3903 --- /dev/null +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.ios.kt @@ -0,0 +1,420 @@ +package io.github.hyochan.kmpiap.openiap + +import kotlin.concurrent.Volatile +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.get +import kotlinx.cinterop.reinterpret +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import platform.Foundation.NSData +import platform.Foundation.NSError +import platform.Foundation.NSHTTPURLResponse +import platform.Foundation.NSMakeRange +import platform.Foundation.NSMutableData +import platform.Foundation.NSMutableURLRequest +import platform.Foundation.NSString +import platform.Foundation.NSURL +import platform.Foundation.appendData +import platform.Foundation.replaceBytesInRange +import platform.Foundation.subdataWithRange +import platform.Foundation.NSURLSession +import platform.Foundation.NSURLSessionConfiguration +import platform.Foundation.NSURLSessionDataDelegateProtocol +import platform.Foundation.NSURLSessionDataTask +import platform.Foundation.NSURLSessionResponseAllow +import platform.Foundation.create +import platform.Foundation.dataUsingEncoding +import platform.Foundation.setHTTPMethod +import platform.Foundation.setValue +import platform.darwin.NSObject +import platform.Foundation.NSUTF8StringEncoding + +/** + * iosMain SSE transport built on NSURLSession via cinterop. Mirrors + * the androidMain shape — same `events(lastEventId)` flow surface, + * same 2-second back-off reconnect. + * + * We deliberately do NOT use Ktor here so kmp-iap's runtime footprint + * stays minimal. The cinterop API surface for NSURLSessionDataDelegate + * is small (one bridging delegate, one per-task channel). + */ +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +actual class WebhookTransport actual constructor( + private val apiKey: String, + private val baseUrl: String, +) { + // Mark both shared-state fields volatile (multiplatform-aware + // `kotlin.concurrent.Volatile`, not the JVM-only annotation) so + // a write from `close()` on the host thread is visible to the + // background `events()` reconnect loop on `Dispatchers.Default`. + // Without this, Kotlin/Native's compiler + memory model allow + // the loop to cache a stale `closed = false` and spin extra + // iterations before noticing the close. Same concern for + // `activeTask`, which `awaitClose` reads to cancel the in- + // flight request. + @Volatile private var closed: Boolean = false + @Volatile private var activeTask: NSURLSessionDataTask? = null + + // Reconnect lifecycle is tied to the collector via callbackFlow: + // when the collector cancels (or close() flips `closed`), awaitClose + // cancels the in-flight NSURLSession task and the launch{} body + // exits via the cancellation exception. The previous detached + // CoroutineScope kept reconnecting in the background even after + // every collector unsubscribed. + actual fun events(lastEventId: String?): Flow = callbackFlow { + val job = launch { + var resumeId = lastEventId + var firstAttempt = true + while (!closed) { + // Always pause between attempts (after the first) — even + // when `runOnce` returns true. NSURLSession completes the + // task on EOF / server-side disconnect; without a pause + // we'd reconnect in a tight loop the moment the kit pod + // recycles. 2s matches the Flutter listener's cadence. + if (!firstAttempt) { + delay(2_000) + } + firstAttempt = false + runOnce(channel, resumeId) { id -> resumeId = id } + if (closed) break + } + channel.close() + } + awaitClose { + // Only cancel collector-scoped work here. The previous + // `closed = true` flipped the instance-wide flag, so once + // any collector cancelled, every subsequent events() + // subscription on the same WebhookTransport returned + // immediately (the launch{} body's `while (!closed)` + // guard short-circuited). Explicit close() remains the + // sole entry point for permanent shutdown. + activeTask?.cancel() + activeTask = null + job.cancel() + } + }.flowOn(Dispatchers.Default) + + private suspend fun runOnce( + channel: SendChannel, + lastEventId: String?, + updateLastEventId: (String) -> Unit, + ): Boolean = try { + val url = NSURL(string = webhookStreamUrl(baseUrl, apiKey)) + // `NSURLRequest.requestWithURL` returns the immutable parent + // type even when invoked on the mutable subclass companion, so + // we cast to NSMutableURLRequest to expose the mutable setters. + // Avoid `apply { }` so Kotlin resolves `setValue` to the ObjC + // `setValue:forHTTPHeaderField:` selector rather than the + // property-delegate operator. + val request: NSMutableURLRequest = + NSMutableURLRequest.requestWithURL(url) as NSMutableURLRequest + request.setHTTPMethod("GET") + request.setValue("text/event-stream", forHTTPHeaderField = "Accept") + request.setValue("no-cache", forHTTPHeaderField = "Cache-Control") + if (lastEventId != null) { + request.setValue(lastEventId, forHTTPHeaderField = "Last-Event-ID") + } + val config = NSURLSessionConfiguration.defaultSessionConfiguration() + // Buffer raw bytes — not a decoded String — so a multi-byte UTF-8 + // character split across two NSURLSession chunks doesn't get + // dropped. `NSString.create(data:, encoding:)` returns null on + // any incomplete UTF-8 sequence at the buffer tail, which would + // silently lose the entire chunk including the head bytes. + val byteBuffer = NSMutableData() + val delegate = SseDelegate( + channel = channel, + byteBuffer = byteBuffer, + updateLastEventId = updateLastEventId, + // Permanent close hook — invoked by the delegate on any + // 4xx response so the outer events() loop exits instead + // of reconnecting forever against a known-bad apiKey / + // unconfigured platform. Mirrors the behaviour of the + // androidMain transport + Flutter / Godot clients. + requestPermanentClose = { closed = true }, + ) + val session = NSURLSession.sessionWithConfiguration(config, delegate, null) + try { + val task = session.dataTaskWithRequest(request) + activeTask = task + task.resume() + delegate.awaitFinished() + true + } finally { + // NSURLSession holds a strong reference to its delegate + // (the SseDelegate above) until the session is invalidated. + // Without this, every reconnect pass leaks the session + + // delegate + buffered NSMutableData chain. + session.finishTasksAndInvalidate() + } + } catch (cancellation: kotlinx.coroutines.CancellationException) { + // Re-throw cancellation so awaitClose / job.cancel() in + // events() propagates correctly. Falling through to the + // generic Throwable branch would convert the cancellation + // to `false`, run one extra reconnect iteration through + // the while-loop, and only terminate on the next delay() + // — violating the cooperative-cancellation contract. + throw cancellation + } catch (error: Throwable) { + false + } finally { + activeTask = null + } + + actual fun close() { + closed = true + activeTask?.cancel() + activeTask = null + } +} + +private val SSE_LINE_SEPARATOR_IOS = Regex("\\r\\n|\\r|\\n") +// Frame separator per WHATWG SSE spec: any two consecutive line +// terminators (CR, LF, or CRLF). Used by the string-level frame loop +// in SseDelegate.flushFrames so it stays in lockstep with the +// byte-level findLastFrameBoundary scanner. +private val SSE_FRAME_SEPARATOR_IOS = Regex("(\\r\\n|\\r|\\n){2}") + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +private class SseDelegate( + private val channel: SendChannel, + private val byteBuffer: NSMutableData, + private val updateLastEventId: (String) -> Unit, + // Invoked by didReceiveResponse when the server returns a 4xx + // status. The outer transport sets `closed = true` so the + // events() loop exits instead of looping back into runOnce + // against a permanently-failing endpoint. + private val requestPermanentClose: () -> Unit, +) : NSObject(), NSURLSessionDataDelegateProtocol { + private val finishedSignal = Channel(Channel.CONFLATED) + // Captured during didReceiveResponse so processFrame can cancel + // the in-flight task on a trySend failure (channel buffer full). + // Without the cancel, the delegate would keep consuming bytes, + // silently dropping events that the collector never saw — and + // the cursor (eventId) would never advance, so a reconnect + // would replay from a stale Last-Event-ID and lose any events + // that the server has already aged past that point. + @Volatile private var currentTask: NSURLSessionDataTask? = null + + // Validate the HTTP status before letting any body bytes flow. + // Without this, a 4xx (bad apiKey, bundle mismatch) or 5xx + // (kit pod restarting) just emits an empty body and the runOnce + // loop silently retries — masking misconfiguration as a + // transient transport blip. Mirrors the Android `responseCode + // !in 200..299` guard. Allowing the response cancels the task + // when status is non-2xx; the awaitFinished signal then + // resolves and runOnce returns false → back-off + reconnect + // applies the same way as a network drop. + @Suppress("UNUSED_PARAMETER") + override fun URLSession( + session: NSURLSession, + dataTask: NSURLSessionDataTask, + didReceiveResponse: platform.Foundation.NSURLResponse, + completionHandler: (platform.Foundation.NSURLSessionResponseDisposition) -> Unit, + ) { + val httpResponse = didReceiveResponse as? NSHTTPURLResponse + val status = httpResponse?.statusCode?.toInt() ?: 0 + if (status in 200..299) { + // Capture so the body callbacks below can cancel this + // task themselves on a trySend backpressure failure. + currentTask = dataTask + completionHandler(NSURLSessionResponseAllow) + } else { + // 4xx (401 INVALID_API_KEY, 412 *_NOT_CONFIGURED) will + // never succeed on retry. Signal the transport to stop + // reconnecting so the consumer sees a clean stream end + // instead of an infinite log spam. 5xx falls through to + // the normal back-off + reconnect because those are + // transient. + if (status in 400..499) { + requestPermanentClose() + } + // Cancel the task; awaitFinished will then unblock and + // runOnce returns false, triggering the same back-off + // reconnect path we use for network errors (or exiting + // the loop entirely if 4xx flipped `closed` above). + dataTask.cancel() + completionHandler( + platform.Foundation.NSURLSessionResponseCancel, + ) + finishedSignal.trySend(Unit) + } + } + + override fun URLSession( + session: NSURLSession, + dataTask: NSURLSessionDataTask, + didReceiveData: NSData, + ) { + // Append the raw chunk to the running byte buffer. SSE frame + // separators (`\n\n` / `\r\n\r\n`) are pure ASCII, so we can + // safely scan for them at the byte level — even when the + // surrounding data contains multi-byte UTF-8 characters. + byteBuffer.appendData(didReceiveData) + val totalLen = byteBuffer.length.toInt() + if (totalLen < 2) return + val bytesPtr = byteBuffer.bytes?.reinterpret() ?: return + val byteAt = { idx: Int -> bytesPtr[idx] } + // Find the LAST complete frame boundary in the buffer. Anything + // after it stays in the buffer for the next chunk — the tail + // might end mid-multibyte-character, and we must not attempt + // to decode it yet. + val consumeUpTo = findLastFrameBoundary(byteAt, totalLen) + if (consumeUpTo <= 0) return + // Decode only the consumable prefix (bytes through the last + // `\n\n` / `\r\n\r\n`) — guaranteed to be a complete UTF-8 + // sequence because the boundary is ASCII and the prior frame + // body must have ended at a clean character boundary for the + // server to have emitted the separator. + val prefixRange = NSMakeRange(0u.toULong(), consumeUpTo.toULong()) + val prefixData = byteBuffer.subdataWithRange(prefixRange) + val prefixNs = + NSString.create(data = prefixData, encoding = NSUTF8StringEncoding) + // Drop the consumed bytes from the head of the buffer regardless + // of whether decode succeeded — if a server ever emits invalid + // UTF-8, dropping the bad frame and keeping the trailing bytes + // is preferable to looping on the same broken prefix forever. + byteBuffer.replaceBytesInRange( + range = prefixRange, + withBytes = null, + length = 0u.toULong(), + ) + if (prefixNs == null) return + // Per WHATWG SSE, a frame separator is two consecutive line + // terminators where each is CR, LF, or CRLF. The byte-level + // findLastFrameBoundary above already honors that, but the + // string-level loop here previously only matched "\n\n" and + // "\r\n\r\n" — for mixed-terminator servers (e.g. "\r\r" or + // "\n\r\n") the byte scanner would consume the bytes but + // this loop would fail to find a separator and stall the + // stream. The regex matches any 2-terminator combination so + // the two scanners stay in lockstep. + var content = prefixNs.toString() + while (true) { + val match = SSE_FRAME_SEPARATOR_IOS.find(content) ?: break + val frame = content.substring(0, match.range.first) + content = content.substring(match.range.last + 1) + processFrame(frame) + } + } + + // Scan for the last complete SSE frame separator (`\n\n` or + // `\r\n\r\n`) and return the byte offset just past it (i.e. the + // number of bytes safe to consume). Returns 0 when no complete + // frame has arrived yet. Operates on raw bytes so a multi-byte + // UTF-8 character in the tail can never produce a false match — + // 0x0A and 0x0D never appear inside the body of a multi-byte + // UTF-8 codepoint. + // WHATWG SSE spec accepts CR, LF, or CRLF as a line terminator, + // and a blank line (frame separator) is *any two consecutive* + // terminators. Mixed-terminator servers (`\r\r`, `\n\r\n`, etc.) + // are spec-compliant and would have been silently dropped by + // the prior \n\n / \r\n\r\n-only check. Same behaviour as the + // Godot client's _terminator_length helper. + private fun findLastFrameBoundary(byteAt: (Int) -> Byte, length: Int): Int { + var lastEnd = 0 + var i = 0 + while (i < length) { + val firstLen = terminatorLength(byteAt, i, length) + if (firstLen == 0) { + i += 1 + continue + } + val secondLen = terminatorLength(byteAt, i + firstLen, length) + if (secondLen == 0) { + i += firstLen + continue + } + lastEnd = i + firstLen + secondLen + i = lastEnd + } + return lastEnd + } + + // Length (1 or 2) of the line terminator starting at `idx`, or 0 + // if the byte at `idx` isn't a terminator. CRLF takes precedence + // so `\r\n` is reported as 2, not 1+1. + private fun terminatorLength(byteAt: (Int) -> Byte, idx: Int, length: Int): Int { + if (idx >= length) return 0 + val b = byteAt(idx) + if (b == 0x0D.toByte() && idx + 1 < length && byteAt(idx + 1) == 0x0A.toByte()) { + return 2 + } + if (b == 0x0A.toByte() || b == 0x0D.toByte()) return 1 + return 0 + } + + override fun URLSession( + session: NSURLSession, + task: platform.Foundation.NSURLSessionTask, + didCompleteWithError: NSError?, + ) { + finishedSignal.trySend(Unit) + } + + suspend fun awaitFinished() { + finishedSignal.receive() + } + + private fun processFrame(frame: String) { + if (frame.isEmpty()) return + var eventId: String? = null + var eventType: String? = null + val data = StringBuilder() + // Per WHATWG SSE, a line within a frame can be terminated by + // CR, LF, or CRLF. Splitting only on '\n' would mis-parse + // CR-only servers (rare but spec-allowed). + for (rawLine in frame.split(SSE_LINE_SEPARATOR_IOS)) { + val stripped = rawLine + if (stripped.startsWith(":")) continue + val colon = stripped.indexOf(':') + if (colon < 0) continue + val field = stripped.substring(0, colon) + var value = stripped.substring(colon + 1) + if (value.startsWith(" ")) value = value.substring(1) + when (field) { + "id" -> eventId = value + "event" -> eventType = value + "data" -> { + if (data.isNotEmpty()) data.append('\n') + data.append(value) + } + } + } + if (eventType == "heartbeat" || eventType == "ready" || data.isEmpty()) { + return + } + // Cursor advances ONLY after a successful enqueue. Advancing + // before the parse / trySend (the prior implementation) would + // move the reconnect cursor past events that never reached the + // consumer — either because the parser returned null on a + // malformed frame, or because the buffered channel rejected + // the trySend. The reconnect would then skip those ids + // forever. If parse fails entirely we still advance so we + // don't loop on the same malformed id. + val event = WebhookEventParser.parse(data.toString()) ?: run { + eventId?.let(updateLastEventId) + return + } + if (channel.trySend(event).isSuccess) { + eventId?.let(updateLastEventId) + } else { + // Channel buffer full → the collector is too slow / has + // stalled. Cancel the task instead of silently dropping + // the event. Reconnect picks up from the last committed + // eventId; if the server has aged events past that point + // the consumer will at least get a transport-level + // signal (the run loop returns false → reconnect) rather + // than a quiet data-loss bug. Don't advance eventId. + currentTask?.cancel() + } + } +} diff --git a/libraries/react-native-iap/src/__tests__/hooks/useWebhookEvents.test.ts b/libraries/react-native-iap/src/__tests__/hooks/useWebhookEvents.test.ts new file mode 100644 index 00000000..6ed432cb --- /dev/null +++ b/libraries/react-native-iap/src/__tests__/hooks/useWebhookEvents.test.ts @@ -0,0 +1,192 @@ +/* eslint-disable import/first */ +import React from 'react'; +import TestRenderer, {act} from 'react-test-renderer'; + +// `useWebhookEvents` doesn't import any RN-native code beyond `react`, +// but it lives next to hooks that do — keep RN mocks consistent with +// the rest of the test suite so jest's react-native preset doesn't +// fail to resolve a transitive import. +jest.mock('react-native', () => ({ + Platform: {OS: 'ios', select: (obj: any) => obj.ios}, +})); + +import {useWebhookEvents} from '../../hooks/useWebhookEvents'; +import type { + WebhookEventPayload, + WebhookEventStream, +} from '../../webhook-client'; + +const validEvent: WebhookEventPayload = { + id: 'uuid-1', + type: 'SubscriptionRenewed', + source: 'AppleAppStoreServerNotificationsV2', + platform: 'IOS', + environment: 'Production', + projectId: 'p-1', + occurredAt: 1_711_000_000_000, + receivedAt: 1_711_000_001_000, + purchaseToken: 'token-1', + productId: 'com.example.premium', + subscriptionState: 'Active', +}; + +function makeFakeStream() { + const listeners: Record< + string, + (event: {data: string; lastEventId?: string}) => void + > = {}; + const stream: WebhookEventStream = { + onmessage: null, + onerror: null, + addEventListener: (type, listener) => { + listeners[type] = listener; + }, + close: jest.fn(), + }; + return { + stream, + fire: (type: string, data: string) => listeners[type]?.({data}), + }; +} + +function HookProbe(props: Parameters[0]) { + const result = useWebhookEvents(props); + // expose into a static slot for the test to read + (HookProbe as any).last = result; + return null; +} + +describe('useWebhookEvents', () => { + afterEach(() => { + (HookProbe as any).last = null; + }); + + it('does nothing when apiKey is empty', () => { + const factory = jest.fn(); + let renderer: ReturnType | null = null; + act(() => { + renderer = TestRenderer.create( + React.createElement(HookProbe, { + apiKey: null, + eventSourceFactory: factory as any, + onEvent: () => {}, + }), + ); + }); + expect(factory).not.toHaveBeenCalled(); + act(() => { + renderer?.unmount(); + }); + }); + + it('opens a stream once apiKey is non-empty and forwards events into the buffer', () => { + const {stream, fire} = makeFakeStream(); + const factory = jest.fn(() => stream); + const onEvent = jest.fn(); + + let renderer: ReturnType | null = null; + act(() => { + renderer = TestRenderer.create( + React.createElement(HookProbe, { + apiKey: 'k', + baseUrl: 'http://localhost', + eventSourceFactory: factory as any, + onEvent, + }), + ); + }); + + expect(factory).toHaveBeenCalledWith( + 'http://localhost/v1/webhooks/stream/k', + {}, + ); + + act(() => { + fire('SubscriptionRenewed', JSON.stringify(validEvent)); + }); + + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({id: 'uuid-1'}), + ); + + const result = (HookProbe as any).last as { + events: WebhookEventPayload[]; + isConnected: boolean; + }; + expect(result.events).toHaveLength(1); + expect(result.events[0]?.id).toBe('uuid-1'); + expect(result.isConnected).toBe(true); + + act(() => { + renderer?.unmount(); + }); + expect(stream.close).toHaveBeenCalled(); + }); + + it('caps the in-memory buffer at bufferSize', () => { + const {stream, fire} = makeFakeStream(); + const factory = jest.fn(() => stream); + + let renderer: ReturnType | null = null; + act(() => { + renderer = TestRenderer.create( + React.createElement(HookProbe, { + apiKey: 'k', + baseUrl: 'http://localhost', + eventSourceFactory: factory as any, + bufferSize: 2, + }), + ); + }); + + act(() => { + fire('SubscriptionRenewed', JSON.stringify({...validEvent, id: 'a'})); + fire('SubscriptionRenewed', JSON.stringify({...validEvent, id: 'b'})); + fire('SubscriptionRenewed', JSON.stringify({...validEvent, id: 'c'})); + }); + + const result = (HookProbe as any).last as { + events: WebhookEventPayload[]; + }; + expect(result.events).toHaveLength(2); + expect(result.events.map((e) => e?.id)).toEqual(['c', 'b']); + + act(() => { + renderer?.unmount(); + }); + }); + + it('reports errors via lastError + onError but keeps the listener alive', () => { + const {stream} = makeFakeStream(); + const factory = jest.fn(() => stream); + const onError = jest.fn(); + + let renderer: ReturnType | null = null; + act(() => { + renderer = TestRenderer.create( + React.createElement(HookProbe, { + apiKey: 'k', + baseUrl: 'http://localhost', + eventSourceFactory: factory as any, + onError, + }), + ); + }); + + act(() => { + stream.onerror?.(new Error('disconnect')); + }); + + const result = (HookProbe as any).last as { + lastError: {code: string} | null; + }; + expect(result.lastError?.code).toBe('TRANSPORT_ERROR'); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({code: 'TRANSPORT_ERROR'}), + ); + + act(() => { + renderer?.unmount(); + }); + }); +}); diff --git a/libraries/react-native-iap/src/hooks/useWebhookEvents.ts b/libraries/react-native-iap/src/hooks/useWebhookEvents.ts new file mode 100644 index 00000000..e6bbc173 --- /dev/null +++ b/libraries/react-native-iap/src/hooks/useWebhookEvents.ts @@ -0,0 +1,180 @@ +import {useEffect, useRef, useState} from 'react'; + +import { + connectWebhookStream, + type WebhookEventPayload, + type WebhookEventStream, + type WebhookListener, + type WebhookListenerError, +} from '../webhook-client'; + +export type UseWebhookEventsOptions = { + /** + * kit project API key — same value used for receipt verification. + * Must be non-empty to start the stream; pass `null`/`undefined` to + * disable the listener (e.g. before the user is logged in). + */ + apiKey: string | null | undefined; + /** + * Override the kit base URL. Defaults to https://kit.openiap.dev. + */ + baseUrl?: string; + /** + * Optional EventSource factory. Required on React Native because RN + * does not ship a global EventSource — pass an instance from + * `react-native-sse` (or any compatible polyfill). + */ + eventSourceFactory?: ( + url: string, + headers: Record, + ) => WebhookEventStream; + /** + * Maximum number of events to retain in the in-memory ring buffer + * surfaced as `events`. Older entries are discarded. Defaults to 50. + * Set 0 to opt out of the buffer entirely (consume only via + * `onEvent`). + */ + bufferSize?: number; + /** + * Called for every received event in addition to being appended to + * the buffer. Useful for side effects (toast, analytics, granting + * entitlement). Called with the latest stable callback identity. + */ + onEvent?: (event: WebhookEventPayload) => void; + /** + * Called when the stream surfaces a transport / parse error. + * EventSource auto-reconnects regardless of this hook — this is + * primarily for telemetry + UI surfacing. + */ + onError?: (error: WebhookListenerError) => void; +}; + +export type UseWebhookEventsResult = { + /** Most recent N events (most-recent-first). Capped at bufferSize. */ + events: WebhookEventPayload[]; + /** Last error reported by the underlying stream. Null when healthy. */ + lastError: WebhookListenerError | null; + /** + * True once the first webhook event has been received from the + * stream. Remains false if the connection is open but idle (the + * underlying SSE bridge doesn't surface a "stream opened" + * lifecycle event we can hook into; isConnected is therefore an + * activity indicator, not a raw socket-state flag). Reset to + * false on cleanup / apiKey change. + */ + isConnected: boolean; +}; + +// React hook wrapping the SSE webhook stream. Lifecycle: +// - opens on mount (once `apiKey` is non-empty), +// - closes on unmount, +// - reconnects automatically when EventSource raises a transport +// error (the underlying client auto-reconnects via the EventSource +// spec; this hook just surfaces the error and re-renders). +// +// Why a hook: openiap's UX guidance is that consumers consume webhook +// events from React state (granting entitlement, refreshing the +// subscription view) rather than via an imperative listener. The +// hook's `events` buffer + `onEvent` callback cover both styles. +export function useWebhookEvents({ + apiKey, + baseUrl, + eventSourceFactory, + bufferSize = 50, + onEvent, + onError, +}: UseWebhookEventsOptions): UseWebhookEventsResult { + const [events, setEvents] = useState([]); + const [lastError, setLastError] = useState(null); + const [isConnected, setIsConnected] = useState(false); + + // Stash callbacks in refs so reconnects don't fire on every render. + // The underlying SSE connection should only restart when `apiKey` / + // `baseUrl` change. `eventSourceFactory` is held in a ref too so + // anonymous-function callers don't tear down the connection every + // render (a common React pitfall — was previously documented as a + // caller-side constraint, now enforced by the hook). `bufferSize` + // is also a ref so adjusting the buffer cap from the host component + // doesn't tear down the stream and lose in-flight events. + const onEventRef = useRef(onEvent); + const onErrorRef = useRef(onError); + const eventSourceFactoryRef = useRef(eventSourceFactory); + const bufferSizeRef = useRef(bufferSize); + onEventRef.current = onEvent; + onErrorRef.current = onError; + eventSourceFactoryRef.current = eventSourceFactory; + bufferSizeRef.current = bufferSize; + + // Trim the visible buffer immediately when bufferSize is lowered + // mid-stream. The ref-based update would otherwise only take + // effect on the next event. + useEffect(() => { + setEvents((prev) => (bufferSize > 0 ? prev.slice(0, bufferSize) : [])); + }, [bufferSize]); + + useEffect(() => { + // Fresh stream → fresh state. Resetting events + lastError on + // (re)connect prevents a stale payload from the previous + // apiKey/baseUrl from briefly leaking into the new context. + setEvents([]); + setLastError(null); + + if (!apiKey) { + return; + } + + let listener: WebhookListener | null = null; + let mounted = true; + + try { + listener = connectWebhookStream({ + apiKey, + baseUrl, + eventSourceFactory: eventSourceFactoryRef.current, + onEvent: (event) => { + if (!mounted) { + return; + } + setIsConnected(true); + const cap = bufferSizeRef.current; + if (cap > 0) { + setEvents((prev) => [event, ...prev].slice(0, cap)); + } + onEventRef.current?.(event); + }, + onError: (error) => { + if (!mounted) { + return; + } + setLastError(error); + onErrorRef.current?.(error); + }, + }); + } catch (error) { + const wrapped: WebhookListenerError = { + code: 'TRANSPORT_ERROR', + message: + error instanceof Error + ? error.message + : 'Failed to open webhook stream', + cause: error, + }; + setLastError(wrapped); + onErrorRef.current?.(wrapped); + } + + return () => { + mounted = false; + listener?.close(); + setIsConnected(false); + }; + // `eventSourceFactory` deliberately omitted from deps — held in a + // ref above so anonymous-function callers don't trigger reconnects + // on every render. The connection is only re-opened when apiKey or + // baseUrl changes; a runtime factory swap is picked up on that + // next reconnect via the ref. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [apiKey, baseUrl]); + + return {events, lastError, isConnected}; +} diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index 11d53380..c516128e 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -124,6 +124,30 @@ export interface EventSubscription { // Export hooks export {useIAP} from './hooks/useIAP'; +export {useWebhookEvents} from './hooks/useWebhookEvents'; +export type { + UseWebhookEventsOptions, + UseWebhookEventsResult, +} from './hooks/useWebhookEvents'; +export { + connectWebhookStream, + parseWebhookEventData, +} from './webhook-client'; +export type { + WebhookEventPayload, + WebhookEventStream, + WebhookEventType as WebhookEventTypeName, + WebhookListener, + WebhookListenerError, + WebhookListenerOptions, +} from './webhook-client'; +export {kitApi, KitApiError} from './kit-api'; +export type { + KitApiOptions, + KitSubscription, + EntitlementsResponse, + StatusResponse, +} from './kit-api'; // Restore completed transactions (cross-platform) // Development utilities removed - use type bridge functions directly if needed diff --git a/libraries/react-native-iap/src/kit-api.ts b/libraries/react-native-iap/src/kit-api.ts new file mode 100644 index 00000000..72f68247 --- /dev/null +++ b/libraries/react-native-iap/src/kit-api.ts @@ -0,0 +1,225 @@ +// Tiny fetch wrapper around kit's `/v1` HTTP surface for use by the JS +// SDK consumers (react-native-iap + expo-iap). Mirrors the shape of +// `packages/mcp-server/src/kit-client.ts` so the same operations are +// reachable from both LLM tools and end-user apps without each +// duplicating the URL layout. + +export type KitApiOptions = { + apiKey: string; + baseUrl?: string; + // Optional fetch override for runtimes without a global (older RN + // builds) or for injection in tests. + fetchImpl?: (input: string, init?: RequestInit) => Promise; +}; + +export type KitSubscription = { + id: string; + productId: string; + platform: "IOS" | "Android"; + state: string; + expiresAt?: number; + renewsAt?: number; + willRenew?: boolean; + cancellationReason?: string; + currency?: string; + priceAmountMicros?: number; + startedAt: number; + updatedAt: number; + purchaseToken: string; + userId?: string; +}; + +export type EntitlementsResponse = { + userId: string; + productIds: string[]; + subscriptions: KitSubscription[]; +}; + +export type StatusResponse = { + active: boolean; + subscription: KitSubscription | null; +}; + +const DEFAULT_BASE_URL = "https://kit.openiap.dev"; + +// Merge caller-supplied headers with kit defaults (`accept`, +// optionally `content-type`). When the runtime exposes a global +// `Headers` constructor we use it directly so callers passing a +// `Headers` instance (a `HeadersInit`) keep that exact instance's +// values. When `Headers` is missing — older React Native builds where +// the operator wires up `fetchImpl` without a `Headers` polyfill — +// we fall back to a case-insensitive merge into a plain record so +// the request still goes through. Either way, caller-set values take +// precedence over kit defaults. +function mergeHeaders( + callerHeaders: HeadersInit | undefined, + hasBody: boolean, +): HeadersInit { + if (typeof Headers === "function") { + const merged = new Headers(callerHeaders); + if (!merged.has("accept")) merged.set("accept", "application/json"); + if (hasBody && !merged.has("content-type")) { + merged.set("content-type", "application/json"); + } + return merged; + } + // Plain-object fallback path. Build a case-insensitive name map + // from whatever the caller passed (Headers-shaped, array-of-pairs, + // or plain record) and re-emit as a record `fetchImpl` accepts. + const lower = new Map(); + const setIfAbsent = (name: string, value: string) => { + const key = name.toLowerCase(); + if (!lower.has(key)) lower.set(key, { name, value }); + }; + const setForce = (name: string, value: string) => { + const key = name.toLowerCase(); + lower.set(key, { name, value }); + }; + if (callerHeaders) { + if (Array.isArray(callerHeaders)) { + for (const [name, value] of callerHeaders) setForce(name, value); + } else if ( + typeof (callerHeaders as { forEach?: unknown }).forEach === "function" + ) { + // `Headers`-like (without being our `typeof Headers === "function"` + // global). RN polyfills sometimes attach `Headers` only to + // request/response instances rather than the global scope. + // Standard signature is `forEach((value, key, parent))`; we + // bind the first two positionally so a polyfill that omits + // the third argument still works. `key` is the header name. + ( + callerHeaders as { + forEach: (cb: (value: string, key: string) => void) => void; + } + ).forEach((value, key) => setForce(key, value)); + } else { + for (const [name, value] of Object.entries( + callerHeaders as Record, + )) { + setForce(name, value); + } + } + } + setIfAbsent("accept", "application/json"); + if (hasBody) setIfAbsent("content-type", "application/json"); + const out: Record = {}; + for (const { name, value } of lower.values()) out[name] = value; + return out; +} + +export class KitApiError extends Error { + constructor( + readonly status: number, + readonly body: unknown, + message: string, + ) { + super(message); + this.name = "KitApiError"; + } +} + +export function kitApi(options: KitApiOptions) { + const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ""); + const fetchImpl: (input: string, init?: RequestInit) => Promise = + (() => { + if (options.fetchImpl) return options.fetchImpl; + if (typeof fetch === "function") { + return (input: string, init?: RequestInit) => fetch(input, init); + } + throw new Error( + "kitApi requires a fetch implementation. Pass `fetchImpl` for runtimes without a global fetch.", + ); + })(); + + async function call(path: string, init?: RequestInit): Promise { + // Normalize headers without depending on a global `Headers` + // constructor: older React Native runtimes ship `fetch` (or a + // polyfill via `fetchImpl`) without exposing `Headers` globally. + // The prior implementation crashed before the first request on + // those runtimes. We use `new Headers()` when available (preserves + // caller-supplied `Headers` instances exactly), and otherwise fall + // back to a small case-insensitive merge into a plain record. + // Either way, kit defaults only apply when the caller hasn't set + // the same name. + const headers = mergeHeaders(init?.headers, init?.body != null); + // Prepend a leading slash if `path` is missing one. Today's + // call sites all hard-code the leading "/", but normalizing here + // makes the helper safe for future additions and matches the + // already-stripped `baseUrl` (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const response = await fetchImpl(`${baseUrl}${normalizedPath}`, { + ...init, + headers, + }); + const text = await response.text(); + // Empty body normalizes to null so callers expecting JSON + // (status / entitlements / list*) don't get a truthy "" + // and crash on property access. + let parsed: unknown = null; + let parseError: unknown = null; + if (text) { + try { + parsed = JSON.parse(text); + } catch (error) { + // Non-JSON body (a misconfigured proxy returning HTML, a + // CDN-injected error page, etc.) on a 2xx response would + // otherwise reach the caller as `parsed = text` and crash + // on property access via `parsed as T`. Throw a structured + // KitApiError instead so callers see a typed failure. + parseError = error; + } + } + if (!response.ok) { + // Surface the raw body (text or parsed) on the error path so + // operators can read the upstream error message verbatim. + throw new KitApiError( + response.status, + parsed ?? text, + `kit ${path} returned ${response.status}`, + ); + } + if (parseError) { + throw new KitApiError( + response.status, + text, + `kit ${path} returned a non-JSON ${response.status} body (${ + parseError instanceof Error ? parseError.message : String(parseError) + })`, + ); + } + return parsed as T; + } + + return { + apiKey: options.apiKey, + baseUrl, + + /** GET /v1/subscriptions/status — the `active` boolean is the + * fastest gate for "is this user paying?". */ + status: (userId: string) => + call( + `/v1/subscriptions/status/${encodeURIComponent(options.apiKey)}?userId=${encodeURIComponent(userId)}`, + ), + + /** GET /v1/subscriptions/entitlements — every productId the user + * is entitled to. Use this when feature gating depends on which + * specific tier the user owns. */ + entitlements: (userId: string) => + call( + `/v1/subscriptions/entitlements/${encodeURIComponent(options.apiKey)}?userId=${encodeURIComponent(userId)}`, + ), + + /** POST /v1/subscriptions/bind-user — call after a successful + * verifyReceipt so kit knows which userId owns the verified + * `purchaseToken`. Idempotent. */ + bindUser: (purchaseToken: string, userId: string) => + call<{ ok: boolean; bound: boolean }>( + `/v1/subscriptions/bind-user/${encodeURIComponent(options.apiKey)}`, + { + method: "POST", + body: JSON.stringify({ purchaseToken, userId }), + }, + ), + }; +} diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 48f0103b..2647f51b 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -1896,6 +1896,8 @@ export interface SubscriptionProductReplacementParamsAndroid { */ export type SubscriptionReplacementModeAndroid = 'unknown-replacement-mode' | 'with-time-proration' | 'charge-prorated-price' | 'charge-full-price' | 'without-proration' | 'deferred' | 'keep-existing'; +export type SubscriptionState = 'active' | 'expired' | 'in-billing-retry' | 'in-grace-period' | 'paused' | 'refunded' | 'revoked' | 'unknown'; + export interface SubscriptionStatusIOS { renewalInfo?: (RenewalInfoIOS | null); state: string; @@ -2057,6 +2059,67 @@ export interface VerifyPurchaseWithProviderResult { export type VoidResult = void; +export type WebhookCancellationReason = 'billing-error' | 'other' | 'price-increase-declined' | 'product-unavailable' | 'refunded' | 'user-canceled'; + +export interface WebhookEvent { + /** Reason for cancellation, when applicable. */ + cancellationReason?: (WebhookCancellationReason | null); + /** Localized currency code (ISO 4217) at event time, when available. */ + currency?: (string | null); + environment: WebhookEventEnvironment; + /** When the current subscription period ends. Epoch milliseconds. */ + expiresAt?: (number | null); + /** + * Stable identifier suitable for idempotency. Derived from the source notification + * UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + * otherwise hashed from the canonicalized payload. + */ + id: string; + /** Time the underlying event occurred at the store. Epoch milliseconds. */ + occurredAt: number; + platform: IapPlatform; + /** + * Price in micros (1/1,000,000 of the currency unit) at event time, when available. + * Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + */ + priceAmountMicros?: (number | null); + /** Product the event pertains to. May be null for account-level events. */ + productId?: (string | null); + /** kit project that owns the subscription / purchase this event refers to. */ + projectId: string; + /** + * Cross-platform purchase identity used to correlate this event with an existing + * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + * Null for `TestNotification` events (Apple ASN v2 / Google RTDN test + * payloads carry no transaction); always present for every other event type. + */ + purchaseToken?: (string | null); + /** + * Original signed payload from the store. ASN v2 events expose the JWS string; + * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + * consumers can independently verify or extract platform-specific fields. kit + * always validates this payload before emitting the event. + */ + rawSignedPayload?: (string | null); + /** Time kit ingested and normalized this event. Epoch milliseconds. */ + receivedAt: number; + /** When auto-renewal will charge again. Epoch milliseconds. */ + renewsAt?: (number | null); + source: WebhookEventSource; + /** + * Normalized subscription state at the time of event, when the event refers to + * a subscription. Null for one-time purchase events. + */ + subscriptionState?: (SubscriptionState | null); + type: WebhookEventType; +} + +export type WebhookEventEnvironment = 'production' | 'sandbox' | 'xcode'; + +export type WebhookEventSource = 'apple-app-store-server-notifications-v2' | 'google-play-real-time-developer-notifications' | 'meta-horizon-reconciler'; + +export type WebhookEventType = 'purchase-consumption-request' | 'purchase-refunded' | 'subscription-canceled' | 'subscription-expired' | 'subscription-in-billing-retry' | 'subscription-in-grace-period' | 'subscription-paused' | 'subscription-price-change' | 'subscription-product-changed' | 'subscription-recovered' | 'subscription-renewed' | 'subscription-resumed' | 'subscription-revoked' | 'subscription-started' | 'subscription-uncanceled' | 'test-notification'; + /** * Win-back offer input for iOS 18+ (StoreKit 2) * Win-back offers are used to re-engage churned subscribers. diff --git a/libraries/react-native-iap/src/webhook-client.ts b/libraries/react-native-iap/src/webhook-client.ts new file mode 100644 index 00000000..ad13e90d --- /dev/null +++ b/libraries/react-native-iap/src/webhook-client.ts @@ -0,0 +1,312 @@ +// Transport-agnostic webhook client for the openiap kit SSE stream +// (`GET /v1/webhooks/stream/{apiKey}`). Used by the JavaScript / TS +// wrappers (react-native-iap, expo-iap) but written without React or +// React-Native imports so it can also run in plain Node, browser, or +// any other JS runtime. +// +// The wire format is documented in `packages/kit/server/api/v1/webhooks.ts` +// and matches the GraphQL `WebhookEvent` shape from `webhook.graphql`. +// +// Parser logic is split out from the connection so it can be unit- +// tested without a live server. See `webhook-client.test.ts`. + +export type WebhookEventType = + | "SubscriptionStarted" + | "SubscriptionRenewed" + | "SubscriptionExpired" + | "SubscriptionInGracePeriod" + | "SubscriptionInBillingRetry" + | "SubscriptionRecovered" + | "SubscriptionCanceled" + | "SubscriptionUncanceled" + | "SubscriptionRevoked" + | "SubscriptionPriceChange" + | "SubscriptionProductChanged" + | "SubscriptionPaused" + | "SubscriptionResumed" + | "PurchaseRefunded" + | "PurchaseConsumptionRequest" + | "TestNotification"; + +export const WEBHOOK_EVENT_TYPES = [ + "SubscriptionStarted", + "SubscriptionRenewed", + "SubscriptionExpired", + "SubscriptionInGracePeriod", + "SubscriptionInBillingRetry", + "SubscriptionRecovered", + "SubscriptionCanceled", + "SubscriptionUncanceled", + "SubscriptionRevoked", + "SubscriptionPriceChange", + "SubscriptionProductChanged", + "SubscriptionPaused", + "SubscriptionResumed", + "PurchaseRefunded", + "PurchaseConsumptionRequest", + "TestNotification", +] as const satisfies readonly WebhookEventType[]; + +export type WebhookEventPayload = { + id: string; + type: WebhookEventType; + source: string; + platform: "IOS" | "Android"; + environment: "Production" | "Sandbox" | "Xcode"; + projectId: string; + occurredAt: number; + receivedAt: number; + // Optional because TestNotification frames carry no transaction; + // every other event type populates this. + purchaseToken?: string; + productId?: string; + subscriptionState?: string; + expiresAt?: number; + renewsAt?: number; + cancellationReason?: string; + currency?: string; + priceAmountMicros?: number; + rawSignedPayload?: string; +}; + +export type WebhookListenerOptions = { + /** + * Project API key. Embedded in the URL path because Apple ASN + * registration cannot send custom headers; the same path is reused + * here for symmetry. + */ + apiKey: string; + /** + * Override the kit base URL. Defaults to https://kit.openiap.dev. + * In tests, point this at a local server. + */ + baseUrl?: string; + /** Called on every successfully-parsed webhook event. */ + onEvent: (event: WebhookEventPayload) => void; + /** + * Called on transport errors. The connection auto-reconnects + * unconditionally; this callback exists for telemetry / surfacing + * to the host UI. + */ + onError?: (error: WebhookListenerError) => void; + /** + * Optional injection of an EventSource constructor. Lets RN / + * Expo plug in `react-native-event-source` when running on a JS + * runtime that lacks the global, or vitest plug in a stub. + */ + eventSourceFactory?: ( + url: string, + headers: Record, + ) => WebhookEventStream; +}; + +export interface WebhookEventStream { + close(): void; + onmessage: ((event: { data: string; lastEventId?: string }) => void) | null; + onerror: ((error: unknown) => void) | null; + addEventListener?: ( + type: string, + listener: (event: { data: string; lastEventId?: string }) => void, + ) => void; +} + +export type WebhookListener = { + /** Tear down the connection and stop receiving events. */ + close(): void; +}; + +export type WebhookListenerError = { + code: + | "TRANSPORT_ERROR" + | "PARSE_ERROR" + | "MALFORMED_EVENT" + | "NO_EVENTSOURCE"; + message: string; + cause?: unknown; +}; + +const DEFAULT_BASE_URL = "https://kit.openiap.dev"; + +export function connectWebhookStream( + options: WebhookListenerOptions, +): WebhookListener { + const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; + const url = `${trimTrailingSlash(baseUrl)}/v1/webhooks/stream/${encodeURIComponent(options.apiKey)}`; + + const factory = options.eventSourceFactory ?? defaultEventSourceFactory; + let stream: WebhookEventStream; + try { + stream = factory(url, {}); + } catch (error) { + options.onError?.({ + code: "NO_EVENTSOURCE", + message: + error instanceof Error + ? error.message + : "EventSource constructor unavailable in this runtime", + cause: error, + }); + return { close: () => {} }; + } + + const seenIds = new Set(); + const seenOrder: string[] = []; + const markSeen = (id: string): boolean => { + if (seenIds.has(id)) { + return true; + } + seenIds.add(id); + seenOrder.push(id); + if (seenOrder.length > 1024) { + const evicted = seenOrder.shift(); + if (evicted !== undefined) { + seenIds.delete(evicted); + } + } + return false; + }; + + const handleData = (raw: string) => { + const parsed = parseWebhookEventData(raw); + if (parsed.kind === "error") { + options.onError?.({ + code: "PARSE_ERROR", + message: parsed.message, + }); + return; + } + if (parsed.kind === "skip") { + return; + } + if (markSeen(parsed.event.id)) { + return; + } + options.onEvent(parsed.event); + }; + + if (typeof stream.addEventListener === "function") { + stream.addEventListener("message", (event) => handleData(event.data)); + // WHATWG EventSource dispatches frames with `event: Foo` only to + // listeners registered for `Foo`, not to `message` / `onmessage`. + // Kit emits webhook frames as typed SSE events, so subscribe to + // every known webhook type and keep `message` for older servers or + // polyfills that collapse typed frames into the generic channel. + for (const eventType of WEBHOOK_EVENT_TYPES) { + stream.addEventListener(eventType, (event) => handleData(event.data)); + } + } else { + stream.onmessage = (event) => handleData(event.data); + } + + stream.onerror = (error) => { + options.onError?.({ + code: "TRANSPORT_ERROR", + message: "SSE transport error (auto-reconnecting)", + cause: error, + }); + }; + + return { + close: () => { + try { + stream.close(); + } catch { + // Closing an already-closed EventSource is a no-op in browsers + // but throws in some polyfills. + } + }, + }; +} + +// --------------------------------------------------------------------------- +// Pure helpers (exported for testing). +// --------------------------------------------------------------------------- + +export type ParsedEventResult = + | { kind: "ok"; event: WebhookEventPayload } + | { kind: "skip"; reason: "heartbeat" | "stream-control" } + | { kind: "error"; message: string }; + +export function parseWebhookEventData(raw: string): ParsedEventResult { + if (!raw) { + return { kind: "skip", reason: "heartbeat" }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + return { + kind: "error", + message: + error instanceof Error + ? `Failed to parse SSE payload: ${error.message}` + : "Failed to parse SSE payload", + }; + } + + if ( + typeof parsed !== "object" || + parsed === null || + !("type" in parsed) || + typeof (parsed as Record).type !== "string" + ) { + // Stream-control messages (the `ready`/`stream-error` envelopes + // emitted by the kit server) have no `type` and are surfaced as + // skips so consumers don't see them as events. + return { kind: "skip", reason: "stream-control" }; + } + + const event = parsed as WebhookEventPayload; + + if ( + typeof event.id !== "string" || + typeof event.occurredAt !== "number" || + typeof event.receivedAt !== "number" + ) { + return { + kind: "error", + message: `WebhookEvent missing required fields (id/occurredAt/receivedAt)`, + }; + } + // purchaseToken is required for every event type *except* + // TestNotification — Apple ASN v2 / Google RTDN test payloads + // carry no transaction. Hard-rejecting here would surface valid + // test webhooks as MALFORMED_EVENT and never reach listeners. + if ( + event.type !== "TestNotification" && + typeof event.purchaseToken !== "string" + ) { + return { + kind: "error", + message: `WebhookEvent missing required field purchaseToken`, + }; + } + + return { kind: "ok", event }; +} + +function trimTrailingSlash(url: string): string { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +function defaultEventSourceFactory( + url: string, + _headers: Record, +): WebhookEventStream { + // EventSource is part of the WHATWG spec and available in all + // browser environments and most JS runtimes (Bun, Node 22+, Deno). + // RN does not ship it natively — consumers must pass + // `eventSourceFactory` from `react-native-sse` or similar. + const ctor = ( + globalThis as { + EventSource?: new (url: string) => WebhookEventStream; + } + ).EventSource; + if (!ctor) { + throw new Error( + "EventSource is not defined. Pass `eventSourceFactory` for runtimes without a built-in EventSource.", + ); + } + return new ctor(url); +} diff --git a/llms.txt b/llms.txt index 18b0e22c..06a89c22 100644 --- a/llms.txt +++ b/llms.txt @@ -132,3 +132,4 @@ All errors use kebab-case `ErrorCode` enum: - APIs: https://openiap.dev/docs/apis - Types: https://openiap.dev/docs/types - Errors: https://openiap.dev/docs/errors +- Webhooks (lifecycle URL setup + SSE consumption): https://openiap.dev/docs/webhooks diff --git a/package.json b/package.json index 8d7d4172..9142b348 100644 --- a/package.json +++ b/package.json @@ -26,5 +26,5 @@ "overrides": { "csstype": "3.2.3" }, - "packageManager": "bun@1.3.0" + "packageManager": "bun@1.3.13" } diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index efacb85c..79115757 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -424,6 +424,113 @@ public enum SubscriptionReplacementModeAndroid: String, Codable, CaseIterable { case keepExisting = "keep-existing" } +public enum SubscriptionState: String, Codable, CaseIterable { + case active = "active" + case inGracePeriod = "in-grace-period" + case inBillingRetry = "in-billing-retry" + case expired = "expired" + case revoked = "revoked" + case refunded = "refunded" + case paused = "paused" + case unknown = "unknown" +} + +public enum WebhookCancellationReason: String, Codable, CaseIterable { + case userCanceled = "user-canceled" + case billingError = "billing-error" + case priceIncreaseDeclined = "price-increase-declined" + case productUnavailable = "product-unavailable" + case refunded = "refunded" + case other = "other" +} + +public enum WebhookEventEnvironment: String, Codable, CaseIterable { + case production = "production" + case sandbox = "sandbox" + case xcode = "xcode" +} + +public enum WebhookEventSource: String, Codable, CaseIterable { + case appleAppStoreServerNotificationsV2 = "apple-app-store-server-notifications-v2" + case googlePlayRealTimeDeveloperNotifications = "google-play-real-time-developer-notifications" + /// Synthetic source for Meta Horizon Store. Meta has no webhook / + /// push notification system so kit polls `verify_entitlement` on a + /// cron and emits these synthetic events when an entitlement + /// transitions. SDK consumers see them on the SSE stream alongside + /// real Apple / Google webhooks. + case metaHorizonReconciler = "meta-horizon-reconciler" +} + +public enum WebhookEventType: String, Codable, CaseIterable { + /// Initial purchase or first conversion from a free trial / intro offer. + /// iOS: SUBSCRIBED (initialBuy / resubscribe). + /// Android: SUBSCRIPTION_PURCHASED. + case subscriptionStarted = "subscription-started" + /// Auto-renewal succeeded for an existing subscription. + /// iOS: DID_RENEW. + /// Android: SUBSCRIPTION_RENEWED. + case subscriptionRenewed = "subscription-renewed" + /// Subscription reached its expiration without a successful renewal. + /// iOS: EXPIRED. + /// Android: SUBSCRIPTION_EXPIRED. + case subscriptionExpired = "subscription-expired" + /// Billing failed; the subscription is in a grace period during which the user + /// retains entitlement while payment is retried. + /// iOS: DID_FAIL_TO_RENEW (with grace period active). + /// Android: SUBSCRIPTION_IN_GRACE_PERIOD. + case subscriptionInGracePeriod = "subscription-in-grace-period" + /// Billing failed and the subscription is in account-hold / billing retry, + /// during which entitlement is paused but the subscription is not yet expired. + /// iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + /// Android: SUBSCRIPTION_ON_HOLD. + case subscriptionInBillingRetry = "subscription-in-billing-retry" + /// Subscription returned to active state after a billing issue or pause. + /// iOS: DID_RECOVER. + /// Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- + /// renew re-enabled (Uncanceled), not billing recovery. + case subscriptionRecovered = "subscription-recovered" + /// User turned off auto-renew. Access continues until the current period ends. + /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). + /// Android: SUBSCRIPTION_CANCELED. + case subscriptionCanceled = "subscription-canceled" + /// User reactivated auto-renew before the subscription expired. + /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). + /// Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + case subscriptionUncanceled = "subscription-uncanceled" + /// Access immediately revoked (family sharing removal, admin action, fraud). + /// iOS: REVOKE. + /// Android: SUBSCRIPTION_REVOKED. + case subscriptionRevoked = "subscription-revoked" + /// A price change is pending or has been confirmed by the user. + /// iOS: PRICE_INCREASE. + /// Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + case subscriptionPriceChange = "subscription-price-change" + /// User upgraded, downgraded, or crossgraded their plan. + /// iOS: DID_CHANGE_RENEWAL_PREF. + /// Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + case subscriptionProductChanged = "subscription-product-changed" + /// Subscription paused (Android only feature). Also fired when the + /// pause schedule is changed — RTDN does not have a separate signal. + /// Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). + case subscriptionPaused = "subscription-paused" + /// Paused subscription resumed (Android only feature). RTDN signals + /// resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle + /// starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the + /// resume. + /// Android: SUBSCRIPTION_RECOVERED (after pause). + case subscriptionResumed = "subscription-resumed" + /// Refund issued for a one-time purchase or subscription period. + /// iOS: REFUND. + /// Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + case purchaseRefunded = "purchase-refunded" + /// iOS-only: App Store requests a consumption status report for a refund decision. + /// Servers should respond via the StoreKit consumption API. + case purchaseConsumptionRequest = "purchase-consumption-request" + /// Sandbox or test notification fired by the store for diagnostic purposes. + /// Useful for verifying webhook plumbing without a live transaction. + case testNotification = "test-notification" +} + // MARK: - Interfaces public protocol ProductCommon: Codable { @@ -1320,6 +1427,49 @@ public struct VerifyPurchaseWithProviderResult: Codable { public typealias VoidResult = Void +public struct WebhookEvent: Codable { + /// Reason for cancellation, when applicable. + public var cancellationReason: WebhookCancellationReason? = nil + /// Localized currency code (ISO 4217) at event time, when available. + public var currency: String? = nil + public var environment: WebhookEventEnvironment + /// When the current subscription period ends. Epoch milliseconds. + public var expiresAt: Double? = nil + /// Stable identifier suitable for idempotency. Derived from the source notification + /// UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + /// otherwise hashed from the canonicalized payload. + public var id: String + /// Time the underlying event occurred at the store. Epoch milliseconds. + public var occurredAt: Double + public var platform: IapPlatform + /// Price in micros (1/1,000,000 of the currency unit) at event time, when available. + /// Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + public var priceAmountMicros: Double? = nil + /// Product the event pertains to. May be null for account-level events. + public var productId: String? = nil + /// kit project that owns the subscription / purchase this event refers to. + public var projectId: String + /// Cross-platform purchase identity used to correlate this event with an existing + /// purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + /// Null for `TestNotification` events (Apple ASN v2 / Google RTDN test + /// payloads carry no transaction); always present for every other event type. + public var purchaseToken: String? = nil + /// Original signed payload from the store. ASN v2 events expose the JWS string; + /// RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + /// consumers can independently verify or extract platform-specific fields. kit + /// always validates this payload before emitting the event. + public var rawSignedPayload: String? = nil + /// Time kit ingested and normalized this event. Epoch milliseconds. + public var receivedAt: Double + /// When auto-renewal will charge again. Epoch milliseconds. + public var renewsAt: Double? = nil + public var source: WebhookEventSource + /// Normalized subscription state at the time of event, when the event refers to + /// a subscription. Null for one-time purchase events. + public var subscriptionState: SubscriptionState? = nil + public var type: WebhookEventType +} + // MARK: - Input Objects public struct AndroidSubscriptionOfferInput: Codable { diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt index aaf8a4d5..8f9eb65c 100644 --- a/packages/docs/public/llms.txt +++ b/packages/docs/public/llms.txt @@ -197,4 +197,5 @@ interface PurchaseError { - Types: https://openiap.dev/docs/types - APIs: https://openiap.dev/docs/apis - Errors: https://openiap.dev/docs/errors +- Webhooks (lifecycle URL setup + SSE consumption): https://openiap.dev/docs/webhooks - GitHub: https://github.com/hyodotdev/openiap diff --git a/packages/docs/src/pages/docs/apis/deep-link-to-subscriptions.tsx b/packages/docs/src/pages/docs/apis/deep-link-to-subscriptions.tsx index 1c8bad03..b28c650f 100644 --- a/packages/docs/src/pages/docs/apis/deep-link-to-subscriptions.tsx +++ b/packages/docs/src/pages/docs/apis/deep-link-to-subscriptions.tsx @@ -197,6 +197,41 @@ await iap.deep_link_to_subscriptions(options)`} }} +

+ Live example:{' '} + + expo-iap + {' '} + ·{' '} + + react-native-iap + {' '} + ·{' '} + + flutter_inapp_purchase + {' '} + ·{' '} + + kmp-iap + +

+

See:{' '} diff --git a/packages/docs/src/pages/docs/apis/get-active-subscriptions.tsx b/packages/docs/src/pages/docs/apis/get-active-subscriptions.tsx index cd8f1ec7..87695837 100644 --- a/packages/docs/src/pages/docs/apis/get-active-subscriptions.tsx +++ b/packages/docs/src/pages/docs/apis/get-active-subscriptions.tsx @@ -206,6 +206,41 @@ function SubscriptionStatus() { }} +

+ Live example:{' '} + + expo-iap + {' '} + ·{' '} + + react-native-iap + {' '} + ·{' '} + + flutter_inapp_purchase + {' '} + ·{' '} + + kmp-iap + +

+

See:{' '} ActiveSubscription diff --git a/packages/docs/src/pages/docs/apis/has-active-subscriptions.tsx b/packages/docs/src/pages/docs/apis/has-active-subscriptions.tsx index d48f6795..6bb04773 100644 --- a/packages/docs/src/pages/docs/apis/has-active-subscriptions.tsx +++ b/packages/docs/src/pages/docs/apis/has-active-subscriptions.tsx @@ -135,6 +135,41 @@ function PremiumGate({ children }: { children: React.ReactNode }) { ), }} + +

+ Live example:{' '} + + expo-iap + {' '} + ·{' '} + + react-native-iap + {' '} + ·{' '} + + flutter_inapp_purchase + {' '} + ·{' '} + + kmp-iap + +

); } diff --git a/packages/docs/src/pages/docs/index.tsx b/packages/docs/src/pages/docs/index.tsx index 2eabdcd1..c2664816 100644 --- a/packages/docs/src/pages/docs/index.tsx +++ b/packages/docs/src/pages/docs/index.tsx @@ -86,6 +86,8 @@ import APIsIsBillingProgramAvailableAndroid from './apis/android/is-billing-prog import APIsLaunchExternalLinkAndroid from './apis/android/launch-external-link-android'; import APIsCreateBillingProgramReportingDetailsAndroid from './apis/android/create-billing-program-reporting-details-android'; import Events from './events'; +import Webhooks from './webhooks'; +import KitBackend from './kit-backend'; import EventsPurchaseUpdatedListener from './events/purchase-updated-listener'; import EventsPurchaseErrorListener from './events/purchase-error-listener'; import EventsSubscriptionBillingIssueListener from './events/subscription-billing-issue-listener'; @@ -1115,6 +1117,8 @@ function Docs() { element={} /> } /> + } /> + } /> } diff --git a/packages/docs/src/pages/docs/kit-backend.tsx b/packages/docs/src/pages/docs/kit-backend.tsx new file mode 100644 index 00000000..bafa0ca8 --- /dev/null +++ b/packages/docs/src/pages/docs/kit-backend.tsx @@ -0,0 +1,230 @@ +import AnchorLink from '../../components/AnchorLink'; +import CodeBlock from '../../components/CodeBlock'; +import LanguageTabs from '../../components/LanguageTabs'; +import SEO from '../../components/SEO'; +import { useScrollToHash } from '../../hooks/useScrollToHash'; + +function KitBackend() { + useScrollToHash(); + + return ( +
+ +

kit backend

+

+ kit (kit.openiap.dev) is the hosted backend you can drop in + instead of running your own server. It handles every step that comes + after a user taps "buy" — receipt validation, lifecycle webhooks, + subscription state, revenue metrics, and App Store Connect / Play + Console product sync — and exposes everything through one URL surface + that all five SDKs and an MCP server speak. +

+ +
+ + Surface map + +

+ Every endpoint takes the project's API key as a path segment so the + same URL works in App Store Connect, Pub/Sub push subscribers, mobile + WebViews, and stdio MCP tools without juggling bearer tokens. +

+
    +
  • + POST /v1/purchase/verify — receipt validation (Apple + JWS, Google purchaseToken, Meta Horizon). +
  • +
  • + POST /v1/webhooks/apple/{apiKey} — App Store + Server Notifications v2 receiver. +
  • +
  • + POST /v1/webhooks/google/{apiKey} — Google + Pub/Sub RTDN receiver (OIDC verified). +
  • +
  • + GET /v1/webhooks/stream/{apiKey} — SSE stream + of normalized WebhookEvents, driven by Convex's + reactive subscribe. +
  • +
  • + GET /v1/subscriptions/status/{apiKey}?userId={' '} + — fast entitlement gate. +
  • +
  • + + GET /v1/subscriptions/entitlements/{apiKey}?userId= + {' '} + — every active productId for a user. +
  • +
  • + GET /v1/subscriptions/list/{apiKey} — + filtered subscription list (for the dashboard). +
  • +
  • + GET /v1/subscriptions/metrics/{apiKey} — MRR, + churn, refund counts. +
  • +
  • + POST /v1/subscriptions/bind-user/{apiKey} — + attach a userId to a verified purchase. +
  • +
  • + GET/POST/DELETE /v1/products/{apiKey} — + kit-side product catalog. +
  • +
  • + + POST /v1/products/{apiKey}/sync/{ios|android} + {' '} + — push-sync with App Store Connect / Play Console. +
  • +
+
+ +
+ + Dashboard UX + +

+ The hosted dashboard at kit.openiap.dev wires every + project-scoped endpoint into a UI: +

+
    +
  • + Subscriptions — live state filtered by{' '} + Active / InGracePeriod /{' '} + InBillingRetry / Expired / etc., with the + metrics summary at the top. +
  • +
  • + Products — kit-side catalog with one-click sync to + App Store Connect (via the project's uploaded .p8 key) + or Play Console (via the service-account JSON). +
  • +
  • + Webhooks — copyable Apple ASN v2 / Google RTDN + endpoints, the SSE stream URL, and a curl recipe for emitting a + synthetic test notification without going through the App Store / + Play Console. +
  • +
+
+ +
+ + Entitlement check from a client + +

+ The fastest gate ("is this user paying?") is one HTTP request. Each + SDK ships a typed wrapper so you don't construct URLs by hand: +

+ + {{ + typescript: ( + {`import { kitApi } from 'react-native-iap'; + +const api = kitApi({ apiKey: process.env.OPENIAP_API_KEY! }); +const { active, subscription } = await api.status('user-1'); +if (active) { + unlockPremium(subscription?.productId); +}`} + ), + dart: ( + {`final api = KitApi(apiKey: const String.fromEnvironment('OPENIAP_API_KEY')); +final status = await api.status('user-1'); +if (status.active) { + unlockPremium(status.subscription?.productId); +}`} + ), + kotlin: ( + {`val api = KitApi(apiKey = System.getenv("OPENIAP_API_KEY")!!) +val status = api.status("user-1") +if (status.active) unlockPremium(status.subscription?.productId)`} + ), + gdscript: ( + {`var api := KitApi.new(api_key) +var status := await api.status("user-1") +if status.active: + unlock_premium(status.subscription.product_id)`} + ), + }} + +
+ +
+ + Product sync + +

+ kit's products table is a cache of every productId your + app uses. The sync action runs against App Store Connect (using a + freshly-minted ES256 JWT signed with the project's .p8) + and Play Developer API (using the project's service account JSON) and + supports three directions: +

+
    +
  • + pull — pull every IAP / subscription from the + upstream store into kit. +
  • +
  • + push — push every state: "Draft" kit + row to the upstream store. +
  • +
  • + both — default; pull then push so the catalog + converges. +
  • +
+

+ The{' '} + + POST /v1/products/{apiKey}/sync/{ios|android} + {' '} + endpoint returns the count of pulled / pushed rows plus per-product + failure messages so the dashboard surfaces upstream rejections + (price-tier conflicts, locale issues, missing review notes) without + dropping silent failures. +

+
+ +
+ + MCP server + +

+ @hyodotdev/openiap-mcp-server is a stdio Model Context + Protocol server with 10 tools covering setup, status checks, + troubleshooting, product CRUD, subscription listing, sandbox + simulation, and full-state inspection. Plug it into Claude Desktop / + Cursor / Codex via: +

+ {`{ + "mcpServers": { + "openiap": { + "command": "bunx", + "args": ["@hyodotdev/openiap-mcp-server"], + "env": { + "OPENIAP_API_KEY": "sk_live_...", + "OPENIAP_BASE_URL": "https://kit.openiap.dev" + } + } + } +}`} +

+ Every tool funnels through the same kit HTTP surface as the dashboard + and the SDKs, so an LLM action ("disable this product on Android") and + a manual edit produce identical state changes. +

+
+
+ ); +} + +export default KitBackend; diff --git a/packages/docs/src/pages/docs/lifecycle/subscription.tsx b/packages/docs/src/pages/docs/lifecycle/subscription.tsx index 57796d2c..010af7fb 100644 --- a/packages/docs/src/pages/docs/lifecycle/subscription.tsx +++ b/packages/docs/src/pages/docs/lifecycle/subscription.tsx @@ -24,6 +24,42 @@ function Subscription() { differently, especially when it comes to renewal information.

+

+ Live example (full subscription flow — fetch, purchase, upgrade / + downgrade, cancellation, restore):{' '} + + expo-iap + {' '} + ·{' '} + + react-native-iap + {' '} + ·{' '} + + flutter_inapp_purchase + {' '} + ·{' '} + + kmp-iap + +

+
Platform Comparison diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index 9d847974..efbbfd55 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -143,7 +143,7 @@ function Releases() { rel="noopener noreferrer" className="external-link" > - PR #105 + PR #105 (https://github.com/hyodotdev/openiap/pull/105) {' '} for the full diff.

@@ -1562,7 +1562,7 @@ product.priceFormatStyle.locale.currencyCode`} target="_blank" rel="noopener noreferrer" > - PR #80 + PR #80 (https://github.com/hyodotdev/openiap/pull/80) diff --git a/packages/docs/src/pages/docs/webhooks.tsx b/packages/docs/src/pages/docs/webhooks.tsx new file mode 100644 index 00000000..4a60e3e0 --- /dev/null +++ b/packages/docs/src/pages/docs/webhooks.tsx @@ -0,0 +1,299 @@ +import AnchorLink from '../../components/AnchorLink'; +import CodeBlock from '../../components/CodeBlock'; +import LanguageTabs from '../../components/LanguageTabs'; +import SEO from '../../components/SEO'; +import { useScrollToHash } from '../../hooks/useScrollToHash'; + +function Webhooks() { + useScrollToHash(); + + return ( +
+ +

Webhooks

+

+ OpenIAP normalizes Apple{' '} + + App Store Server Notifications v2 + {' '} + and Google{' '} + + Real-Time Developer Notifications + {' '} + into a single cross-store event stream and pushes them straight to your + client SDK over Server-Sent Events. Apps can react to renewals, + billing-retry, refunds, and revokes without operating any backend of + their own. +

+ +
+ + Architecture + +

+ The kit service hosted at https://kit.openiap.dev is + registered as the webhook endpoint with Apple and Google. It verifies + each notification's signature, normalizes the payload into the spec's{' '} + WebhookEvent shape, dedups on the source notification id, + and stores the result for at least 30 days. Authenticated SDK clients + connect to GET /v1/webhooks/stream/{apiKey} and + receive new events as Server-Sent Events along with reconnect support + via the Last-Event-ID header. +

+
+ +
+ + Setup — wiring the lifecycle webhook URL + +

+ Open the kit dashboard's Webhooks tab and copy the + single POST /v1/webhooks/{apiKey} URL. Paste it + into both store consoles below — kit auto-detects the payload shape + (Apple ASN v2 vs Google Pub/Sub) and dispatches to the right verifier, + so one URL covers both stores. +

+

Apple — App Store Server Notifications v2

+
    +
  1. + Sign in to{' '} + + App Store Connect + {' '} + → My Apps → your app. +
  2. +
  3. + Sidebar → App Information. Scroll to{' '} + App Store Server Notifications. +
  4. +
  5. + Set Version to Version 2. Paste the + kit URL into both Production Server URL and{' '} + Sandbox Server URL. +
  6. +
  7. + Save, then click Send Test Notification. A{' '} + TestNotification event should appear in the Webhooks + tab within seconds. +
  8. +
+

Google — Real-Time Developer Notifications

+
    +
  1. + + Google Cloud Console + {' '} + → select the project linked to your Play Console app →{' '} + Pub/Sub → Topics → Create topic (e.g.{' '} + play-rtdn). +
  2. +
  3. + On that topic → Subscriptions → Create subscription + . Delivery type Push; Endpoint URL{' '} + = the kit URL. Enable Authentication with a service + account that has the{' '} + roles/iam.serviceAccountTokenCreator role on itself, + and set the OIDC Audience to your kit deployment + origin. +
  4. +
  5. + Grant roles/pubsub.publisher on the topic to{' '} + + google-play-developer-notifications@system.gserviceaccount.com + + . +
  6. +
  7. + + Play Console + {' '} + → your app → Monetization setup →{' '} + Real-time developer notifications. Paste the topic + name (projects/<gcp-project>/topics/play-rtdn) →{' '} + Send test notification. +
  8. +
+

+ Tip: the lifecycle webhook URL is{' '} + POST-only. Opening it in a browser shows a blank / + 404 page — that's expected. Use the dashboard's Live test{' '} + curl recipe (or App Store Connect / Pub/Sub's "Send test notification" + buttons) to verify wiring. +

+
+ +
+ + Consuming the SSE stream + +

+ The second URL —{' '} + GET /v1/webhooks/stream/{apiKey} — is a + long-lived text/event-stream response, not an HTML page. + Open it in a browser and you'll see a blank tab; that's correct + behavior because the response never closes and only emits + comment-style keepalive frames (:keepalive\n\n) until a + real WebhookEvent arrives. To actually consume it use one + of the SDK helpers below or call it directly with{' '} + EventSource / curl -N. +

+
+ +
+ + Event shape + +

+ Each event delivered over the SSE stream conforms to the GraphQL{' '} + WebhookEvent type defined in{' '} + packages/gql/src/webhook.graphql. The unified event types + are: +

+
    +
  • + SubscriptionStarted, SubscriptionRenewed, + SubscriptionExpired +
  • +
  • + SubscriptionInGracePeriod,{' '} + SubscriptionInBillingRetry,{' '} + SubscriptionRecovered +
  • +
  • + SubscriptionCanceled,{' '} + SubscriptionUncanceled,{' '} + SubscriptionRevoked +
  • +
  • + SubscriptionPriceChange,{' '} + SubscriptionProductChanged,{' '} + SubscriptionPaused, SubscriptionResumed +
  • +
  • + PurchaseRefunded,{' '} + PurchaseConsumptionRequest,{' '} + TestNotification +
  • +
+

+ The id field is the stable per-notification identifier ( + notificationUUID on Apple, messageId on + Google) — use it for application-level idempotency. The full source ↔ + openiap mapping table lives at{' '} + knowledge/external/webhook-mapping.md. +

+
+ +
+ + Usage + + + {{ + typescript: ( + {`// react-native-iap (and expo-iap) ship a useWebhookEvents hook. +import { useWebhookEvents } from 'react-native-iap'; +// React Native does not ship a global EventSource; pass one in. +import EventSource from 'react-native-sse'; + +const { events, lastError, isConnected } = useWebhookEvents({ + apiKey: process.env.OPENIAP_API_KEY!, + // baseUrl defaults to https://kit.openiap.dev + eventSourceFactory: (url) => new EventSource(url), + onEvent: (event) => { + if (event.type === 'SubscriptionRenewed') { + grantEntitlement(event.purchaseToken); + } + }, +});`} + ), + dart: ( + {`import 'package:flutter_inapp_purchase/webhook_client.dart'; + +final listener = connectWebhookStream(apiKey: 'sk_live_...'); +listener.events.listen((event) { + if (event.type == WebhookEventType.SubscriptionRenewed) { + grantEntitlement(event.purchaseToken); + } +});`} + ), + kotlin: ( + {`import io.github.hyochan.kmpiap.openiap.WebhookEventParser +import io.github.hyochan.kmpiap.openiap.webhookStreamUrl + +// Pure parser + types live in commonMain. Wire your platform's HTTP +// client to webhookStreamUrl(apiKey = "...") and feed each SSE +// data frame to WebhookEventParser.parse(). +val event = WebhookEventParser.parse(rawJson) ?: return +when (event.type) { + WebhookEventType.SubscriptionRenewed -> grantEntitlement(event.purchaseToken) + else -> Unit +}`} + ), + gdscript: ( + {`extends Node + +@onready var webhook := preload("res://addons/godot-iap/webhook_client.gd").new() + +func _ready() -> void: + webhook.api_key = "sk_live_..." + webhook.event_received.connect(_on_event) + add_child(webhook) + webhook.connect_stream() + +func _on_event(event: Dictionary) -> void: + if event["type"] == "SubscriptionRenewed": + grant_entitlement(event["purchaseToken"])`} + ), + }} + +
+ +
+ + Reconnect and replay + +

+ The SSE stream auto-reconnects on transport errors. The standard{' '} + Last-Event-ID header is honored — kit looks up the named + event's receivedAt and resumes from there, so events that + fired while the connection was closed are delivered in order on the + next connect. +

+

+ For long-offline reconciliation, call the{' '} + webhookEventsSince Convex query directly with a + checkpoint timestamp; it returns up to 500 events at a time, capped at + the 30-day retention window. +

+
+
+ ); +} + +export default Webhooks; diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index 9697826f..de782cf0 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -962,6 +962,265 @@ public enum class SubscriptionReplacementModeAndroid(val rawValue: String) { fun toJson(): String = rawValue } +public enum class SubscriptionState(val rawValue: String) { + Active("active"), + InGracePeriod("in-grace-period"), + InBillingRetry("in-billing-retry"), + Expired("expired"), + Revoked("revoked"), + Refunded("refunded"), + Paused("paused"), + Unknown("unknown"); + + companion object { + fun fromJson(value: String): SubscriptionState = when (value) { + "active" -> SubscriptionState.Active + "Active" -> SubscriptionState.Active + "in-grace-period" -> SubscriptionState.InGracePeriod + "InGracePeriod" -> SubscriptionState.InGracePeriod + "in-billing-retry" -> SubscriptionState.InBillingRetry + "InBillingRetry" -> SubscriptionState.InBillingRetry + "expired" -> SubscriptionState.Expired + "Expired" -> SubscriptionState.Expired + "revoked" -> SubscriptionState.Revoked + "Revoked" -> SubscriptionState.Revoked + "refunded" -> SubscriptionState.Refunded + "Refunded" -> SubscriptionState.Refunded + "paused" -> SubscriptionState.Paused + "Paused" -> SubscriptionState.Paused + "unknown" -> SubscriptionState.Unknown + "Unknown" -> SubscriptionState.Unknown + else -> throw IllegalArgumentException("Unknown SubscriptionState value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookCancellationReason(val rawValue: String) { + UserCanceled("user-canceled"), + BillingError("billing-error"), + PriceIncreaseDeclined("price-increase-declined"), + ProductUnavailable("product-unavailable"), + Refunded("refunded"), + Other("other"); + + companion object { + fun fromJson(value: String): WebhookCancellationReason = when (value) { + "user-canceled" -> WebhookCancellationReason.UserCanceled + "USER_CANCELED" -> WebhookCancellationReason.UserCanceled + "UserCanceled" -> WebhookCancellationReason.UserCanceled + "billing-error" -> WebhookCancellationReason.BillingError + "BILLING_ERROR" -> WebhookCancellationReason.BillingError + "BillingError" -> WebhookCancellationReason.BillingError + "price-increase-declined" -> WebhookCancellationReason.PriceIncreaseDeclined + "PRICE_INCREASE_DECLINED" -> WebhookCancellationReason.PriceIncreaseDeclined + "PriceIncreaseDeclined" -> WebhookCancellationReason.PriceIncreaseDeclined + "product-unavailable" -> WebhookCancellationReason.ProductUnavailable + "PRODUCT_UNAVAILABLE" -> WebhookCancellationReason.ProductUnavailable + "ProductUnavailable" -> WebhookCancellationReason.ProductUnavailable + "refunded" -> WebhookCancellationReason.Refunded + "REFUNDED" -> WebhookCancellationReason.Refunded + "Refunded" -> WebhookCancellationReason.Refunded + "other" -> WebhookCancellationReason.Other + "OTHER" -> WebhookCancellationReason.Other + "Other" -> WebhookCancellationReason.Other + else -> throw IllegalArgumentException("Unknown WebhookCancellationReason value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventEnvironment(val rawValue: String) { + Production("production"), + Sandbox("sandbox"), + Xcode("xcode"); + + companion object { + fun fromJson(value: String): WebhookEventEnvironment = when (value) { + "production" -> WebhookEventEnvironment.Production + "Production" -> WebhookEventEnvironment.Production + "sandbox" -> WebhookEventEnvironment.Sandbox + "Sandbox" -> WebhookEventEnvironment.Sandbox + "xcode" -> WebhookEventEnvironment.Xcode + "Xcode" -> WebhookEventEnvironment.Xcode + else -> throw IllegalArgumentException("Unknown WebhookEventEnvironment value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventSource(val rawValue: String) { + AppleAppStoreServerNotificationsV2("apple-app-store-server-notifications-v2"), + GooglePlayRealTimeDeveloperNotifications("google-play-real-time-developer-notifications"), + /** + * Synthetic source for Meta Horizon Store. Meta has no webhook / + * push notification system so kit polls `verify_entitlement` on a + * cron and emits these synthetic events when an entitlement + * transitions. SDK consumers see them on the SSE stream alongside + * real Apple / Google webhooks. + */ + MetaHorizonReconciler("meta-horizon-reconciler"); + + companion object { + fun fromJson(value: String): WebhookEventSource = when (value) { + "apple-app-store-server-notifications-v2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "AppleAppStoreServerNotificationsV2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "google-play-real-time-developer-notifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "GooglePlayRealTimeDeveloperNotifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "meta-horizon-reconciler" -> WebhookEventSource.MetaHorizonReconciler + "MetaHorizonReconciler" -> WebhookEventSource.MetaHorizonReconciler + else -> throw IllegalArgumentException("Unknown WebhookEventSource value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventType(val rawValue: String) { + /** + * Initial purchase or first conversion from a free trial / intro offer. + * iOS: SUBSCRIBED (initialBuy / resubscribe). + * Android: SUBSCRIPTION_PURCHASED. + */ + SubscriptionStarted("subscription-started"), + /** + * Auto-renewal succeeded for an existing subscription. + * iOS: DID_RENEW. + * Android: SUBSCRIPTION_RENEWED. + */ + SubscriptionRenewed("subscription-renewed"), + /** + * Subscription reached its expiration without a successful renewal. + * iOS: EXPIRED. + * Android: SUBSCRIPTION_EXPIRED. + */ + SubscriptionExpired("subscription-expired"), + /** + * Billing failed; the subscription is in a grace period during which the user + * retains entitlement while payment is retried. + * iOS: DID_FAIL_TO_RENEW (with grace period active). + * Android: SUBSCRIPTION_IN_GRACE_PERIOD. + */ + SubscriptionInGracePeriod("subscription-in-grace-period"), + /** + * Billing failed and the subscription is in account-hold / billing retry, + * during which entitlement is paused but the subscription is not yet expired. + * iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + * Android: SUBSCRIPTION_ON_HOLD. + */ + SubscriptionInBillingRetry("subscription-in-billing-retry"), + /** + * Subscription returned to active state after a billing issue or pause. + * iOS: DID_RECOVER. + * Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- + * renew re-enabled (Uncanceled), not billing recovery. + */ + SubscriptionRecovered("subscription-recovered"), + /** + * User turned off auto-renew. Access continues until the current period ends. + * iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). + * Android: SUBSCRIPTION_CANCELED. + */ + SubscriptionCanceled("subscription-canceled"), + /** + * User reactivated auto-renew before the subscription expired. + * iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). + * Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + */ + SubscriptionUncanceled("subscription-uncanceled"), + /** + * Access immediately revoked (family sharing removal, admin action, fraud). + * iOS: REVOKE. + * Android: SUBSCRIPTION_REVOKED. + */ + SubscriptionRevoked("subscription-revoked"), + /** + * A price change is pending or has been confirmed by the user. + * iOS: PRICE_INCREASE. + * Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + */ + SubscriptionPriceChange("subscription-price-change"), + /** + * User upgraded, downgraded, or crossgraded their plan. + * iOS: DID_CHANGE_RENEWAL_PREF. + * Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + */ + SubscriptionProductChanged("subscription-product-changed"), + /** + * Subscription paused (Android only feature). Also fired when the + * pause schedule is changed — RTDN does not have a separate signal. + * Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). + */ + SubscriptionPaused("subscription-paused"), + /** + * Paused subscription resumed (Android only feature). RTDN signals + * resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle + * starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the + * resume. + * Android: SUBSCRIPTION_RECOVERED (after pause). + */ + SubscriptionResumed("subscription-resumed"), + /** + * Refund issued for a one-time purchase or subscription period. + * iOS: REFUND. + * Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + */ + PurchaseRefunded("purchase-refunded"), + /** + * iOS-only: App Store requests a consumption status report for a refund decision. + * Servers should respond via the StoreKit consumption API. + */ + PurchaseConsumptionRequest("purchase-consumption-request"), + /** + * Sandbox or test notification fired by the store for diagnostic purposes. + * Useful for verifying webhook plumbing without a live transaction. + */ + TestNotification("test-notification"); + + companion object { + fun fromJson(value: String): WebhookEventType = when (value) { + "subscription-started" -> WebhookEventType.SubscriptionStarted + "SubscriptionStarted" -> WebhookEventType.SubscriptionStarted + "subscription-renewed" -> WebhookEventType.SubscriptionRenewed + "SubscriptionRenewed" -> WebhookEventType.SubscriptionRenewed + "subscription-expired" -> WebhookEventType.SubscriptionExpired + "SubscriptionExpired" -> WebhookEventType.SubscriptionExpired + "subscription-in-grace-period" -> WebhookEventType.SubscriptionInGracePeriod + "SubscriptionInGracePeriod" -> WebhookEventType.SubscriptionInGracePeriod + "subscription-in-billing-retry" -> WebhookEventType.SubscriptionInBillingRetry + "SubscriptionInBillingRetry" -> WebhookEventType.SubscriptionInBillingRetry + "subscription-recovered" -> WebhookEventType.SubscriptionRecovered + "SubscriptionRecovered" -> WebhookEventType.SubscriptionRecovered + "subscription-canceled" -> WebhookEventType.SubscriptionCanceled + "SubscriptionCanceled" -> WebhookEventType.SubscriptionCanceled + "subscription-uncanceled" -> WebhookEventType.SubscriptionUncanceled + "SubscriptionUncanceled" -> WebhookEventType.SubscriptionUncanceled + "subscription-revoked" -> WebhookEventType.SubscriptionRevoked + "SubscriptionRevoked" -> WebhookEventType.SubscriptionRevoked + "subscription-price-change" -> WebhookEventType.SubscriptionPriceChange + "SubscriptionPriceChange" -> WebhookEventType.SubscriptionPriceChange + "subscription-product-changed" -> WebhookEventType.SubscriptionProductChanged + "SubscriptionProductChanged" -> WebhookEventType.SubscriptionProductChanged + "subscription-paused" -> WebhookEventType.SubscriptionPaused + "SubscriptionPaused" -> WebhookEventType.SubscriptionPaused + "subscription-resumed" -> WebhookEventType.SubscriptionResumed + "SubscriptionResumed" -> WebhookEventType.SubscriptionResumed + "purchase-refunded" -> WebhookEventType.PurchaseRefunded + "PurchaseRefunded" -> WebhookEventType.PurchaseRefunded + "purchase-consumption-request" -> WebhookEventType.PurchaseConsumptionRequest + "PurchaseConsumptionRequest" -> WebhookEventType.PurchaseConsumptionRequest + "test-notification" -> WebhookEventType.TestNotification + "TestNotification" -> WebhookEventType.TestNotification + else -> throw IllegalArgumentException("Unknown WebhookEventType value: $value") + } + } + + fun toJson(): String = rawValue +} + // MARK: - Interfaces public interface ProductCommon { @@ -3587,6 +3846,121 @@ public data class VerifyPurchaseWithProviderResult( public typealias VoidResult = Unit +public data class WebhookEvent( + /** + * Reason for cancellation, when applicable. + */ + val cancellationReason: WebhookCancellationReason? = null, + /** + * Localized currency code (ISO 4217) at event time, when available. + */ + val currency: String? = null, + val environment: WebhookEventEnvironment, + /** + * When the current subscription period ends. Epoch milliseconds. + */ + val expiresAt: Double? = null, + /** + * Stable identifier suitable for idempotency. Derived from the source notification + * UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + * otherwise hashed from the canonicalized payload. + */ + val id: String, + /** + * Time the underlying event occurred at the store. Epoch milliseconds. + */ + val occurredAt: Double, + val platform: IapPlatform, + /** + * Price in micros (1/1,000,000 of the currency unit) at event time, when available. + * Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + */ + val priceAmountMicros: Double? = null, + /** + * Product the event pertains to. May be null for account-level events. + */ + val productId: String? = null, + /** + * kit project that owns the subscription / purchase this event refers to. + */ + val projectId: String, + /** + * Cross-platform purchase identity used to correlate this event with an existing + * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + * Null for `TestNotification` events (Apple ASN v2 / Google RTDN test + * payloads carry no transaction); always present for every other event type. + */ + val purchaseToken: String? = null, + /** + * Original signed payload from the store. ASN v2 events expose the JWS string; + * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + * consumers can independently verify or extract platform-specific fields. kit + * always validates this payload before emitting the event. + */ + val rawSignedPayload: String? = null, + /** + * Time kit ingested and normalized this event. Epoch milliseconds. + */ + val receivedAt: Double, + /** + * When auto-renewal will charge again. Epoch milliseconds. + */ + val renewsAt: Double? = null, + val source: WebhookEventSource, + /** + * Normalized subscription state at the time of event, when the event refers to + * a subscription. Null for one-time purchase events. + */ + val subscriptionState: SubscriptionState? = null, + val type: WebhookEventType +) { + + companion object { + fun fromJson(json: Map): WebhookEvent { + return WebhookEvent( + cancellationReason = (json["cancellationReason"] as? String)?.let { WebhookCancellationReason.fromJson(it) }, + currency = json["currency"] as? String, + environment = (json["environment"] as? String)?.let { WebhookEventEnvironment.fromJson(it) } ?: WebhookEventEnvironment.Production, + expiresAt = (json["expiresAt"] as? Number)?.toDouble(), + id = json["id"] as? String ?: "", + occurredAt = (json["occurredAt"] as? Number)?.toDouble() ?: 0.0, + platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios, + priceAmountMicros = (json["priceAmountMicros"] as? Number)?.toDouble(), + productId = json["productId"] as? String, + projectId = json["projectId"] as? String ?: "", + purchaseToken = json["purchaseToken"] as? String, + rawSignedPayload = json["rawSignedPayload"] as? String, + receivedAt = (json["receivedAt"] as? Number)?.toDouble() ?: 0.0, + renewsAt = (json["renewsAt"] as? Number)?.toDouble(), + source = (json["source"] as? String)?.let { WebhookEventSource.fromJson(it) } ?: WebhookEventSource.AppleAppStoreServerNotificationsV2, + subscriptionState = (json["subscriptionState"] as? String)?.let { SubscriptionState.fromJson(it) }, + type = (json["type"] as? String)?.let { WebhookEventType.fromJson(it) } ?: WebhookEventType.SubscriptionStarted, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "WebhookEvent", + "cancellationReason" to cancellationReason?.toJson(), + "currency" to currency, + "environment" to environment.toJson(), + "expiresAt" to expiresAt, + "id" to id, + "occurredAt" to occurredAt, + "platform" to platform.toJson(), + "priceAmountMicros" to priceAmountMicros, + "productId" to productId, + "projectId" to projectId, + "purchaseToken" to purchaseToken, + "rawSignedPayload" to rawSignedPayload, + "receivedAt" to receivedAt, + "renewsAt" to renewsAt, + "source" to source.toJson(), + "subscriptionState" to subscriptionState?.toJson(), + "type" to type.toJson(), + ) +} + // MARK: - Input Objects public data class AndroidSubscriptionOfferInput( diff --git a/packages/gql/codegen.ts b/packages/gql/codegen.ts index 9ec107cf..6bc8cb7e 100644 --- a/packages/gql/codegen.ts +++ b/packages/gql/codegen.ts @@ -11,6 +11,7 @@ const config: CodegenConfig = { 'src/api-android.graphql', 'src/error.graphql', 'src/event.graphql', + 'src/webhook.graphql', ], generates: { 'src/generated/types.ts': { diff --git a/packages/gql/codegen/core/parser.ts b/packages/gql/codegen/core/parser.ts index 86e9a5ec..48c64433 100644 --- a/packages/gql/codegen/core/parser.ts +++ b/packages/gql/codegen/core/parser.ts @@ -33,6 +33,7 @@ const DEFAULT_SCHEMA_PATHS = [ '../src/api-android.graphql', '../src/error.graphql', '../src/event.graphql', + '../src/webhook.graphql', ]; // ============================================================================ diff --git a/packages/gql/package.json b/packages/gql/package.json index 27fff14f..7dd62db6 100644 --- a/packages/gql/package.json +++ b/packages/gql/package.json @@ -5,6 +5,7 @@ "main": "src/generated/types.ts", "exports": { ".": "./src/generated/types.ts", + "./webhook-client": "./src/webhook-client.ts", "./swift": "./src/generated/Types.swift", "./kotlin": "./src/generated/Types.kt", "./dart": "./src/generated/types.dart", diff --git a/packages/gql/scripts/fix-generated-types.mjs b/packages/gql/scripts/fix-generated-types.mjs index 77cfafc5..adafa4ba 100644 --- a/packages/gql/scripts/fix-generated-types.mjs +++ b/packages/gql/scripts/fix-generated-types.mjs @@ -10,6 +10,11 @@ const schemaFiles = [ resolve(__dirname, '../src/api.graphql'), resolve(__dirname, '../src/api-ios.graphql'), resolve(__dirname, '../src/api-android.graphql'), + // webhook.graphql adds `webhookEventsSince` to the Query interface + // and marks it `# Future` so it gets the Promise<> wrap that all + // async query fields require. Without this entry, the marker would + // be silently ignored — caught in PR #123 (https://github.com/hyodotdev/openiap/pull/123) review. + resolve(__dirname, '../src/webhook.graphql'), ]; const schemaDefinitionFiles = [ '../src/schema.graphql', diff --git a/packages/gql/scripts/sync-to-platforms.mjs b/packages/gql/scripts/sync-to-platforms.mjs index 4fd1b3bc..5f76ed84 100755 --- a/packages/gql/scripts/sync-to-platforms.mjs +++ b/packages/gql/scripts/sync-to-platforms.mjs @@ -43,6 +43,30 @@ const tsSource = resolve(gqlRoot, 'src/generated/types.ts'); const rnTsTarget = resolve(monorepoRoot, 'libraries/react-native-iap/src/types.ts'); const expoTsTarget = resolve(monorepoRoot, 'libraries/expo-iap/src/types.ts'); +// `webhook-client.ts` is a hand-maintained runtime helper rather than +// generated output, but it lives in `packages/gql` so RN and Expo can +// share a single canonical implementation. Sync alongside the types so +// the two never drift. +const webhookClientSource = resolve(gqlRoot, 'src/webhook-client.ts'); +const rnWebhookClientTarget = resolve( + monorepoRoot, + 'libraries/react-native-iap/src/webhook-client.ts', +); +const expoWebhookClientTarget = resolve( + monorepoRoot, + 'libraries/expo-iap/src/webhook-client.ts', +); + +const kitApiSource = resolve(gqlRoot, 'src/kit-api.ts'); +const rnKitApiTarget = resolve( + monorepoRoot, + 'libraries/react-native-iap/src/kit-api.ts', +); +const expoKitApiTarget = resolve( + monorepoRoot, + 'libraries/expo-iap/src/kit-api.ts', +); + const kmpSource = resolve(gqlRoot, 'src/generated/Types.kt'); const kmpTarget = resolve( monorepoRoot, @@ -114,6 +138,29 @@ if (existsSync(tsSource)) { console.log(` ${expoTsTarget}\n`); } +// Sync the webhook client to react-native-iap + expo-iap. Doing this +// during type-sync means the per-library copies can never silently +// drift from the canonical implementation in `packages/gql`. +if (existsSync(webhookClientSource)) { + for (const target of [rnWebhookClientTarget, expoWebhookClientTarget]) { + mkdirSync(dirname(target), { recursive: true }); + copyFileSync(webhookClientSource, target); + } + console.log('✅ webhook-client → react-native-iap + expo-iap'); + console.log(` ${rnWebhookClientTarget}`); + console.log(` ${expoWebhookClientTarget}\n`); +} + +if (existsSync(kitApiSource)) { + for (const target of [rnKitApiTarget, expoKitApiTarget]) { + mkdirSync(dirname(target), { recursive: true }); + copyFileSync(kitApiSource, target); + } + console.log('✅ kit-api → react-native-iap + expo-iap'); + console.log(` ${rnKitApiTarget}`); + console.log(` ${expoKitApiTarget}\n`); +} + // Sync Kotlin to kmp-iap with the library-specific package declaration and // the enum-companion semicolon that Kotlin requires. This mirrors the // post-process that packages/google runs; without it the KMP module would diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index 75ffa755..0d073c5d 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -1051,6 +1051,295 @@ public enum class SubscriptionReplacementModeAndroid(val rawValue: String) { fun toJson(): String = rawValue } +public enum class SubscriptionState(val rawValue: String) { + Active("active"), + InGracePeriod("in-grace-period"), + InBillingRetry("in-billing-retry"), + Expired("expired"), + Revoked("revoked"), + Refunded("refunded"), + Paused("paused"), + Unknown("unknown") + + companion object { + fun fromJson(value: String): SubscriptionState = when (value) { + "active" -> SubscriptionState.Active + "ACTIVE" -> SubscriptionState.Active + "Active" -> SubscriptionState.Active + "in-grace-period" -> SubscriptionState.InGracePeriod + "IN_GRACE_PERIOD" -> SubscriptionState.InGracePeriod + "InGracePeriod" -> SubscriptionState.InGracePeriod + "in-billing-retry" -> SubscriptionState.InBillingRetry + "IN_BILLING_RETRY" -> SubscriptionState.InBillingRetry + "InBillingRetry" -> SubscriptionState.InBillingRetry + "expired" -> SubscriptionState.Expired + "EXPIRED" -> SubscriptionState.Expired + "Expired" -> SubscriptionState.Expired + "revoked" -> SubscriptionState.Revoked + "REVOKED" -> SubscriptionState.Revoked + "Revoked" -> SubscriptionState.Revoked + "refunded" -> SubscriptionState.Refunded + "REFUNDED" -> SubscriptionState.Refunded + "Refunded" -> SubscriptionState.Refunded + "paused" -> SubscriptionState.Paused + "PAUSED" -> SubscriptionState.Paused + "Paused" -> SubscriptionState.Paused + "unknown" -> SubscriptionState.Unknown + "UNKNOWN" -> SubscriptionState.Unknown + "Unknown" -> SubscriptionState.Unknown + else -> throw IllegalArgumentException("Unknown SubscriptionState value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookCancellationReason(val rawValue: String) { + UserCanceled("user-canceled"), + BillingError("billing-error"), + PriceIncreaseDeclined("price-increase-declined"), + ProductUnavailable("product-unavailable"), + Refunded("refunded"), + Other("other") + + companion object { + fun fromJson(value: String): WebhookCancellationReason = when (value) { + "user-canceled" -> WebhookCancellationReason.UserCanceled + "USER_CANCELED" -> WebhookCancellationReason.UserCanceled + "UserCanceled" -> WebhookCancellationReason.UserCanceled + "billing-error" -> WebhookCancellationReason.BillingError + "BILLING_ERROR" -> WebhookCancellationReason.BillingError + "BillingError" -> WebhookCancellationReason.BillingError + "price-increase-declined" -> WebhookCancellationReason.PriceIncreaseDeclined + "PRICE_INCREASE_DECLINED" -> WebhookCancellationReason.PriceIncreaseDeclined + "PriceIncreaseDeclined" -> WebhookCancellationReason.PriceIncreaseDeclined + "product-unavailable" -> WebhookCancellationReason.ProductUnavailable + "PRODUCT_UNAVAILABLE" -> WebhookCancellationReason.ProductUnavailable + "ProductUnavailable" -> WebhookCancellationReason.ProductUnavailable + "refunded" -> WebhookCancellationReason.Refunded + "REFUNDED" -> WebhookCancellationReason.Refunded + "Refunded" -> WebhookCancellationReason.Refunded + "other" -> WebhookCancellationReason.Other + "OTHER" -> WebhookCancellationReason.Other + "Other" -> WebhookCancellationReason.Other + else -> throw IllegalArgumentException("Unknown WebhookCancellationReason value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventEnvironment(val rawValue: String) { + Production("production"), + Sandbox("sandbox"), + Xcode("xcode") + + companion object { + fun fromJson(value: String): WebhookEventEnvironment = when (value) { + "production" -> WebhookEventEnvironment.Production + "PRODUCTION" -> WebhookEventEnvironment.Production + "Production" -> WebhookEventEnvironment.Production + "sandbox" -> WebhookEventEnvironment.Sandbox + "SANDBOX" -> WebhookEventEnvironment.Sandbox + "Sandbox" -> WebhookEventEnvironment.Sandbox + "xcode" -> WebhookEventEnvironment.Xcode + "XCODE" -> WebhookEventEnvironment.Xcode + "Xcode" -> WebhookEventEnvironment.Xcode + else -> throw IllegalArgumentException("Unknown WebhookEventEnvironment value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventSource(val rawValue: String) { + AppleAppStoreServerNotificationsV2("apple-app-store-server-notifications-v2"), + GooglePlayRealTimeDeveloperNotifications("google-play-real-time-developer-notifications"), + /** + * Synthetic source for Meta Horizon Store. Meta has no webhook / + * push notification system so kit polls `verify_entitlement` on a + * cron and emits these synthetic events when an entitlement + * transitions. SDK consumers see them on the SSE stream alongside + * real Apple / Google webhooks. + */ + MetaHorizonReconciler("meta-horizon-reconciler") + + companion object { + fun fromJson(value: String): WebhookEventSource = when (value) { + "apple-app-store-server-notifications-v2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "AppleAppStoreServerNotificationsV2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "google-play-real-time-developer-notifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "GooglePlayRealTimeDeveloperNotifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "meta-horizon-reconciler" -> WebhookEventSource.MetaHorizonReconciler + "META_HORIZON_RECONCILER" -> WebhookEventSource.MetaHorizonReconciler + "MetaHorizonReconciler" -> WebhookEventSource.MetaHorizonReconciler + else -> throw IllegalArgumentException("Unknown WebhookEventSource value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventType(val rawValue: String) { + /** + * Initial purchase or first conversion from a free trial / intro offer. + * iOS: SUBSCRIBED (initialBuy / resubscribe). + * Android: SUBSCRIPTION_PURCHASED. + */ + SubscriptionStarted("subscription-started"), + /** + * Auto-renewal succeeded for an existing subscription. + * iOS: DID_RENEW. + * Android: SUBSCRIPTION_RENEWED. + */ + SubscriptionRenewed("subscription-renewed"), + /** + * Subscription reached its expiration without a successful renewal. + * iOS: EXPIRED. + * Android: SUBSCRIPTION_EXPIRED. + */ + SubscriptionExpired("subscription-expired"), + /** + * Billing failed; the subscription is in a grace period during which the user + * retains entitlement while payment is retried. + * iOS: DID_FAIL_TO_RENEW (with grace period active). + * Android: SUBSCRIPTION_IN_GRACE_PERIOD. + */ + SubscriptionInGracePeriod("subscription-in-grace-period"), + /** + * Billing failed and the subscription is in account-hold / billing retry, + * during which entitlement is paused but the subscription is not yet expired. + * iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + * Android: SUBSCRIPTION_ON_HOLD. + */ + SubscriptionInBillingRetry("subscription-in-billing-retry"), + /** + * Subscription returned to active state after a billing issue or pause. + * iOS: DID_RECOVER. + * Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- + * renew re-enabled (Uncanceled), not billing recovery. + */ + SubscriptionRecovered("subscription-recovered"), + /** + * User turned off auto-renew. Access continues until the current period ends. + * iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). + * Android: SUBSCRIPTION_CANCELED. + */ + SubscriptionCanceled("subscription-canceled"), + /** + * User reactivated auto-renew before the subscription expired. + * iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). + * Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + */ + SubscriptionUncanceled("subscription-uncanceled"), + /** + * Access immediately revoked (family sharing removal, admin action, fraud). + * iOS: REVOKE. + * Android: SUBSCRIPTION_REVOKED. + */ + SubscriptionRevoked("subscription-revoked"), + /** + * A price change is pending or has been confirmed by the user. + * iOS: PRICE_INCREASE. + * Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + */ + SubscriptionPriceChange("subscription-price-change"), + /** + * User upgraded, downgraded, or crossgraded their plan. + * iOS: DID_CHANGE_RENEWAL_PREF. + * Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + */ + SubscriptionProductChanged("subscription-product-changed"), + /** + * Subscription paused (Android only feature). Also fired when the + * pause schedule is changed — RTDN does not have a separate signal. + * Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). + */ + SubscriptionPaused("subscription-paused"), + /** + * Paused subscription resumed (Android only feature). RTDN signals + * resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle + * starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the + * resume. + * Android: SUBSCRIPTION_RECOVERED (after pause). + */ + SubscriptionResumed("subscription-resumed"), + /** + * Refund issued for a one-time purchase or subscription period. + * iOS: REFUND. + * Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + */ + PurchaseRefunded("purchase-refunded"), + /** + * iOS-only: App Store requests a consumption status report for a refund decision. + * Servers should respond via the StoreKit consumption API. + */ + PurchaseConsumptionRequest("purchase-consumption-request"), + /** + * Sandbox or test notification fired by the store for diagnostic purposes. + * Useful for verifying webhook plumbing without a live transaction. + */ + TestNotification("test-notification") + + companion object { + fun fromJson(value: String): WebhookEventType = when (value) { + "subscription-started" -> WebhookEventType.SubscriptionStarted + "SUBSCRIPTION_STARTED" -> WebhookEventType.SubscriptionStarted + "SubscriptionStarted" -> WebhookEventType.SubscriptionStarted + "subscription-renewed" -> WebhookEventType.SubscriptionRenewed + "SUBSCRIPTION_RENEWED" -> WebhookEventType.SubscriptionRenewed + "SubscriptionRenewed" -> WebhookEventType.SubscriptionRenewed + "subscription-expired" -> WebhookEventType.SubscriptionExpired + "SUBSCRIPTION_EXPIRED" -> WebhookEventType.SubscriptionExpired + "SubscriptionExpired" -> WebhookEventType.SubscriptionExpired + "subscription-in-grace-period" -> WebhookEventType.SubscriptionInGracePeriod + "SUBSCRIPTION_IN_GRACE_PERIOD" -> WebhookEventType.SubscriptionInGracePeriod + "SubscriptionInGracePeriod" -> WebhookEventType.SubscriptionInGracePeriod + "subscription-in-billing-retry" -> WebhookEventType.SubscriptionInBillingRetry + "SUBSCRIPTION_IN_BILLING_RETRY" -> WebhookEventType.SubscriptionInBillingRetry + "SubscriptionInBillingRetry" -> WebhookEventType.SubscriptionInBillingRetry + "subscription-recovered" -> WebhookEventType.SubscriptionRecovered + "SUBSCRIPTION_RECOVERED" -> WebhookEventType.SubscriptionRecovered + "SubscriptionRecovered" -> WebhookEventType.SubscriptionRecovered + "subscription-canceled" -> WebhookEventType.SubscriptionCanceled + "SUBSCRIPTION_CANCELED" -> WebhookEventType.SubscriptionCanceled + "SubscriptionCanceled" -> WebhookEventType.SubscriptionCanceled + "subscription-uncanceled" -> WebhookEventType.SubscriptionUncanceled + "SUBSCRIPTION_UNCANCELED" -> WebhookEventType.SubscriptionUncanceled + "SubscriptionUncanceled" -> WebhookEventType.SubscriptionUncanceled + "subscription-revoked" -> WebhookEventType.SubscriptionRevoked + "SUBSCRIPTION_REVOKED" -> WebhookEventType.SubscriptionRevoked + "SubscriptionRevoked" -> WebhookEventType.SubscriptionRevoked + "subscription-price-change" -> WebhookEventType.SubscriptionPriceChange + "SUBSCRIPTION_PRICE_CHANGE" -> WebhookEventType.SubscriptionPriceChange + "SubscriptionPriceChange" -> WebhookEventType.SubscriptionPriceChange + "subscription-product-changed" -> WebhookEventType.SubscriptionProductChanged + "SUBSCRIPTION_PRODUCT_CHANGED" -> WebhookEventType.SubscriptionProductChanged + "SubscriptionProductChanged" -> WebhookEventType.SubscriptionProductChanged + "subscription-paused" -> WebhookEventType.SubscriptionPaused + "SUBSCRIPTION_PAUSED" -> WebhookEventType.SubscriptionPaused + "SubscriptionPaused" -> WebhookEventType.SubscriptionPaused + "subscription-resumed" -> WebhookEventType.SubscriptionResumed + "SUBSCRIPTION_RESUMED" -> WebhookEventType.SubscriptionResumed + "SubscriptionResumed" -> WebhookEventType.SubscriptionResumed + "purchase-refunded" -> WebhookEventType.PurchaseRefunded + "PURCHASE_REFUNDED" -> WebhookEventType.PurchaseRefunded + "PurchaseRefunded" -> WebhookEventType.PurchaseRefunded + "purchase-consumption-request" -> WebhookEventType.PurchaseConsumptionRequest + "PURCHASE_CONSUMPTION_REQUEST" -> WebhookEventType.PurchaseConsumptionRequest + "PurchaseConsumptionRequest" -> WebhookEventType.PurchaseConsumptionRequest + "test-notification" -> WebhookEventType.TestNotification + "TEST_NOTIFICATION" -> WebhookEventType.TestNotification + "TestNotification" -> WebhookEventType.TestNotification + else -> throw IllegalArgumentException("Unknown WebhookEventType value: $value") + } + } + + fun toJson(): String = rawValue +} + // MARK: - Interfaces public interface ProductCommon { @@ -3676,6 +3965,121 @@ public data class VerifyPurchaseWithProviderResult( public typealias VoidResult = Unit +public data class WebhookEvent( + /** + * Reason for cancellation, when applicable. + */ + val cancellationReason: WebhookCancellationReason? = null, + /** + * Localized currency code (ISO 4217) at event time, when available. + */ + val currency: String? = null, + val environment: WebhookEventEnvironment, + /** + * When the current subscription period ends. Epoch milliseconds. + */ + val expiresAt: Double? = null, + /** + * Stable identifier suitable for idempotency. Derived from the source notification + * UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + * otherwise hashed from the canonicalized payload. + */ + val id: String, + /** + * Time the underlying event occurred at the store. Epoch milliseconds. + */ + val occurredAt: Double, + val platform: IapPlatform, + /** + * Price in micros (1/1,000,000 of the currency unit) at event time, when available. + * Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + */ + val priceAmountMicros: Double? = null, + /** + * Product the event pertains to. May be null for account-level events. + */ + val productId: String? = null, + /** + * kit project that owns the subscription / purchase this event refers to. + */ + val projectId: String, + /** + * Cross-platform purchase identity used to correlate this event with an existing + * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + * Null for `TestNotification` events (Apple ASN v2 / Google RTDN test + * payloads carry no transaction); always present for every other event type. + */ + val purchaseToken: String? = null, + /** + * Original signed payload from the store. ASN v2 events expose the JWS string; + * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + * consumers can independently verify or extract platform-specific fields. kit + * always validates this payload before emitting the event. + */ + val rawSignedPayload: String? = null, + /** + * Time kit ingested and normalized this event. Epoch milliseconds. + */ + val receivedAt: Double, + /** + * When auto-renewal will charge again. Epoch milliseconds. + */ + val renewsAt: Double? = null, + val source: WebhookEventSource, + /** + * Normalized subscription state at the time of event, when the event refers to + * a subscription. Null for one-time purchase events. + */ + val subscriptionState: SubscriptionState? = null, + val type: WebhookEventType +) { + + companion object { + fun fromJson(json: Map): WebhookEvent { + return WebhookEvent( + cancellationReason = (json["cancellationReason"] as? String)?.let { WebhookCancellationReason.fromJson(it) }, + currency = json["currency"] as? String, + environment = (json["environment"] as? String)?.let { WebhookEventEnvironment.fromJson(it) } ?: WebhookEventEnvironment.Production, + expiresAt = (json["expiresAt"] as? Number)?.toDouble(), + id = json["id"] as? String ?: "", + occurredAt = (json["occurredAt"] as? Number)?.toDouble() ?: 0.0, + platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios, + priceAmountMicros = (json["priceAmountMicros"] as? Number)?.toDouble(), + productId = json["productId"] as? String, + projectId = json["projectId"] as? String ?: "", + purchaseToken = json["purchaseToken"] as? String, + rawSignedPayload = json["rawSignedPayload"] as? String, + receivedAt = (json["receivedAt"] as? Number)?.toDouble() ?: 0.0, + renewsAt = (json["renewsAt"] as? Number)?.toDouble(), + source = (json["source"] as? String)?.let { WebhookEventSource.fromJson(it) } ?: WebhookEventSource.AppleAppStoreServerNotificationsV2, + subscriptionState = (json["subscriptionState"] as? String)?.let { SubscriptionState.fromJson(it) }, + type = (json["type"] as? String)?.let { WebhookEventType.fromJson(it) } ?: WebhookEventType.SubscriptionStarted, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "WebhookEvent", + "cancellationReason" to cancellationReason?.toJson(), + "currency" to currency, + "environment" to environment.toJson(), + "expiresAt" to expiresAt, + "id" to id, + "occurredAt" to occurredAt, + "platform" to platform.toJson(), + "priceAmountMicros" to priceAmountMicros, + "productId" to productId, + "projectId" to projectId, + "purchaseToken" to purchaseToken, + "rawSignedPayload" to rawSignedPayload, + "receivedAt" to receivedAt, + "renewsAt" to renewsAt, + "source" to source.toJson(), + "subscriptionState" to subscriptionState?.toJson(), + "type" to type.toJson(), + ) +} + // MARK: - Input Objects public data class AndroidSubscriptionOfferInput( diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index efacb85c..79115757 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -424,6 +424,113 @@ public enum SubscriptionReplacementModeAndroid: String, Codable, CaseIterable { case keepExisting = "keep-existing" } +public enum SubscriptionState: String, Codable, CaseIterable { + case active = "active" + case inGracePeriod = "in-grace-period" + case inBillingRetry = "in-billing-retry" + case expired = "expired" + case revoked = "revoked" + case refunded = "refunded" + case paused = "paused" + case unknown = "unknown" +} + +public enum WebhookCancellationReason: String, Codable, CaseIterable { + case userCanceled = "user-canceled" + case billingError = "billing-error" + case priceIncreaseDeclined = "price-increase-declined" + case productUnavailable = "product-unavailable" + case refunded = "refunded" + case other = "other" +} + +public enum WebhookEventEnvironment: String, Codable, CaseIterable { + case production = "production" + case sandbox = "sandbox" + case xcode = "xcode" +} + +public enum WebhookEventSource: String, Codable, CaseIterable { + case appleAppStoreServerNotificationsV2 = "apple-app-store-server-notifications-v2" + case googlePlayRealTimeDeveloperNotifications = "google-play-real-time-developer-notifications" + /// Synthetic source for Meta Horizon Store. Meta has no webhook / + /// push notification system so kit polls `verify_entitlement` on a + /// cron and emits these synthetic events when an entitlement + /// transitions. SDK consumers see them on the SSE stream alongside + /// real Apple / Google webhooks. + case metaHorizonReconciler = "meta-horizon-reconciler" +} + +public enum WebhookEventType: String, Codable, CaseIterable { + /// Initial purchase or first conversion from a free trial / intro offer. + /// iOS: SUBSCRIBED (initialBuy / resubscribe). + /// Android: SUBSCRIPTION_PURCHASED. + case subscriptionStarted = "subscription-started" + /// Auto-renewal succeeded for an existing subscription. + /// iOS: DID_RENEW. + /// Android: SUBSCRIPTION_RENEWED. + case subscriptionRenewed = "subscription-renewed" + /// Subscription reached its expiration without a successful renewal. + /// iOS: EXPIRED. + /// Android: SUBSCRIPTION_EXPIRED. + case subscriptionExpired = "subscription-expired" + /// Billing failed; the subscription is in a grace period during which the user + /// retains entitlement while payment is retried. + /// iOS: DID_FAIL_TO_RENEW (with grace period active). + /// Android: SUBSCRIPTION_IN_GRACE_PERIOD. + case subscriptionInGracePeriod = "subscription-in-grace-period" + /// Billing failed and the subscription is in account-hold / billing retry, + /// during which entitlement is paused but the subscription is not yet expired. + /// iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + /// Android: SUBSCRIPTION_ON_HOLD. + case subscriptionInBillingRetry = "subscription-in-billing-retry" + /// Subscription returned to active state after a billing issue or pause. + /// iOS: DID_RECOVER. + /// Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- + /// renew re-enabled (Uncanceled), not billing recovery. + case subscriptionRecovered = "subscription-recovered" + /// User turned off auto-renew. Access continues until the current period ends. + /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). + /// Android: SUBSCRIPTION_CANCELED. + case subscriptionCanceled = "subscription-canceled" + /// User reactivated auto-renew before the subscription expired. + /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). + /// Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + case subscriptionUncanceled = "subscription-uncanceled" + /// Access immediately revoked (family sharing removal, admin action, fraud). + /// iOS: REVOKE. + /// Android: SUBSCRIPTION_REVOKED. + case subscriptionRevoked = "subscription-revoked" + /// A price change is pending or has been confirmed by the user. + /// iOS: PRICE_INCREASE. + /// Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + case subscriptionPriceChange = "subscription-price-change" + /// User upgraded, downgraded, or crossgraded their plan. + /// iOS: DID_CHANGE_RENEWAL_PREF. + /// Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + case subscriptionProductChanged = "subscription-product-changed" + /// Subscription paused (Android only feature). Also fired when the + /// pause schedule is changed — RTDN does not have a separate signal. + /// Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). + case subscriptionPaused = "subscription-paused" + /// Paused subscription resumed (Android only feature). RTDN signals + /// resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle + /// starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the + /// resume. + /// Android: SUBSCRIPTION_RECOVERED (after pause). + case subscriptionResumed = "subscription-resumed" + /// Refund issued for a one-time purchase or subscription period. + /// iOS: REFUND. + /// Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + case purchaseRefunded = "purchase-refunded" + /// iOS-only: App Store requests a consumption status report for a refund decision. + /// Servers should respond via the StoreKit consumption API. + case purchaseConsumptionRequest = "purchase-consumption-request" + /// Sandbox or test notification fired by the store for diagnostic purposes. + /// Useful for verifying webhook plumbing without a live transaction. + case testNotification = "test-notification" +} + // MARK: - Interfaces public protocol ProductCommon: Codable { @@ -1320,6 +1427,49 @@ public struct VerifyPurchaseWithProviderResult: Codable { public typealias VoidResult = Void +public struct WebhookEvent: Codable { + /// Reason for cancellation, when applicable. + public var cancellationReason: WebhookCancellationReason? = nil + /// Localized currency code (ISO 4217) at event time, when available. + public var currency: String? = nil + public var environment: WebhookEventEnvironment + /// When the current subscription period ends. Epoch milliseconds. + public var expiresAt: Double? = nil + /// Stable identifier suitable for idempotency. Derived from the source notification + /// UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + /// otherwise hashed from the canonicalized payload. + public var id: String + /// Time the underlying event occurred at the store. Epoch milliseconds. + public var occurredAt: Double + public var platform: IapPlatform + /// Price in micros (1/1,000,000 of the currency unit) at event time, when available. + /// Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + public var priceAmountMicros: Double? = nil + /// Product the event pertains to. May be null for account-level events. + public var productId: String? = nil + /// kit project that owns the subscription / purchase this event refers to. + public var projectId: String + /// Cross-platform purchase identity used to correlate this event with an existing + /// purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + /// Null for `TestNotification` events (Apple ASN v2 / Google RTDN test + /// payloads carry no transaction); always present for every other event type. + public var purchaseToken: String? = nil + /// Original signed payload from the store. ASN v2 events expose the JWS string; + /// RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + /// consumers can independently verify or extract platform-specific fields. kit + /// always validates this payload before emitting the event. + public var rawSignedPayload: String? = nil + /// Time kit ingested and normalized this event. Epoch milliseconds. + public var receivedAt: Double + /// When auto-renewal will charge again. Epoch milliseconds. + public var renewsAt: Double? = nil + public var source: WebhookEventSource + /// Normalized subscription state at the time of event, when the event refers to + /// a subscription. Null for one-time purchase events. + public var subscriptionState: SubscriptionState? = nil + public var type: WebhookEventType +} + // MARK: - Input Objects public struct AndroidSubscriptionOfferInput: Codable { diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 1645922e..210dd596 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -928,6 +928,245 @@ enum SubscriptionReplacementModeAndroid { String toJson() => value; } +enum SubscriptionState { + Active('active'), + InGracePeriod('in-grace-period'), + InBillingRetry('in-billing-retry'), + Expired('expired'), + Revoked('revoked'), + Refunded('refunded'), + Paused('paused'), + Unknown('unknown'); + + const SubscriptionState(this.value); + final String value; + + factory SubscriptionState.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'active': + return SubscriptionState.Active; + case 'in-grace-period': + return SubscriptionState.InGracePeriod; + case 'in-billing-retry': + return SubscriptionState.InBillingRetry; + case 'expired': + return SubscriptionState.Expired; + case 'revoked': + return SubscriptionState.Revoked; + case 'refunded': + return SubscriptionState.Refunded; + case 'paused': + return SubscriptionState.Paused; + case 'unknown': + return SubscriptionState.Unknown; + } + throw ArgumentError('Unknown SubscriptionState value: $value'); + } + + String toJson() => value; +} + +enum WebhookCancellationReason { + UserCanceled('user-canceled'), + BillingError('billing-error'), + PriceIncreaseDeclined('price-increase-declined'), + ProductUnavailable('product-unavailable'), + Refunded('refunded'), + Other('other'); + + const WebhookCancellationReason(this.value); + final String value; + + factory WebhookCancellationReason.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'user-canceled': + return WebhookCancellationReason.UserCanceled; + case 'billing-error': + return WebhookCancellationReason.BillingError; + case 'price-increase-declined': + return WebhookCancellationReason.PriceIncreaseDeclined; + case 'product-unavailable': + return WebhookCancellationReason.ProductUnavailable; + case 'refunded': + return WebhookCancellationReason.Refunded; + case 'other': + return WebhookCancellationReason.Other; + } + throw ArgumentError('Unknown WebhookCancellationReason value: $value'); + } + + String toJson() => value; +} + +enum WebhookEventEnvironment { + Production('production'), + Sandbox('sandbox'), + Xcode('xcode'); + + const WebhookEventEnvironment(this.value); + final String value; + + factory WebhookEventEnvironment.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'production': + return WebhookEventEnvironment.Production; + case 'sandbox': + return WebhookEventEnvironment.Sandbox; + case 'xcode': + return WebhookEventEnvironment.Xcode; + } + throw ArgumentError('Unknown WebhookEventEnvironment value: $value'); + } + + String toJson() => value; +} + +enum WebhookEventSource { + AppleAppStoreServerNotificationsV2('apple-app-store-server-notifications-v2'), + GooglePlayRealTimeDeveloperNotifications('google-play-real-time-developer-notifications'), + /// Synthetic source for Meta Horizon Store. Meta has no webhook / + /// push notification system so kit polls `verify_entitlement` on a + /// cron and emits these synthetic events when an entitlement + /// transitions. SDK consumers see them on the SSE stream alongside + /// real Apple / Google webhooks. + MetaHorizonReconciler('meta-horizon-reconciler'); + + const WebhookEventSource(this.value); + final String value; + + factory WebhookEventSource.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'apple-app-store-server-notifications-v2': + return WebhookEventSource.AppleAppStoreServerNotificationsV2; + case 'google-play-real-time-developer-notifications': + return WebhookEventSource.GooglePlayRealTimeDeveloperNotifications; + case 'meta-horizon-reconciler': + return WebhookEventSource.MetaHorizonReconciler; + } + throw ArgumentError('Unknown WebhookEventSource value: $value'); + } + + String toJson() => value; +} + +enum WebhookEventType { + /// Initial purchase or first conversion from a free trial / intro offer. + /// iOS: SUBSCRIBED (initialBuy / resubscribe). + /// Android: SUBSCRIPTION_PURCHASED. + SubscriptionStarted('subscription-started'), + /// Auto-renewal succeeded for an existing subscription. + /// iOS: DID_RENEW. + /// Android: SUBSCRIPTION_RENEWED. + SubscriptionRenewed('subscription-renewed'), + /// Subscription reached its expiration without a successful renewal. + /// iOS: EXPIRED. + /// Android: SUBSCRIPTION_EXPIRED. + SubscriptionExpired('subscription-expired'), + /// Billing failed; the subscription is in a grace period during which the user + /// retains entitlement while payment is retried. + /// iOS: DID_FAIL_TO_RENEW (with grace period active). + /// Android: SUBSCRIPTION_IN_GRACE_PERIOD. + SubscriptionInGracePeriod('subscription-in-grace-period'), + /// Billing failed and the subscription is in account-hold / billing retry, + /// during which entitlement is paused but the subscription is not yet expired. + /// iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + /// Android: SUBSCRIPTION_ON_HOLD. + SubscriptionInBillingRetry('subscription-in-billing-retry'), + /// Subscription returned to active state after a billing issue or pause. + /// iOS: DID_RECOVER. + /// Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- + /// renew re-enabled (Uncanceled), not billing recovery. + SubscriptionRecovered('subscription-recovered'), + /// User turned off auto-renew. Access continues until the current period ends. + /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). + /// Android: SUBSCRIPTION_CANCELED. + SubscriptionCanceled('subscription-canceled'), + /// User reactivated auto-renew before the subscription expired. + /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). + /// Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + SubscriptionUncanceled('subscription-uncanceled'), + /// Access immediately revoked (family sharing removal, admin action, fraud). + /// iOS: REVOKE. + /// Android: SUBSCRIPTION_REVOKED. + SubscriptionRevoked('subscription-revoked'), + /// A price change is pending or has been confirmed by the user. + /// iOS: PRICE_INCREASE. + /// Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + SubscriptionPriceChange('subscription-price-change'), + /// User upgraded, downgraded, or crossgraded their plan. + /// iOS: DID_CHANGE_RENEWAL_PREF. + /// Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + SubscriptionProductChanged('subscription-product-changed'), + /// Subscription paused (Android only feature). Also fired when the + /// pause schedule is changed — RTDN does not have a separate signal. + /// Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). + SubscriptionPaused('subscription-paused'), + /// Paused subscription resumed (Android only feature). RTDN signals + /// resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle + /// starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the + /// resume. + /// Android: SUBSCRIPTION_RECOVERED (after pause). + SubscriptionResumed('subscription-resumed'), + /// Refund issued for a one-time purchase or subscription period. + /// iOS: REFUND. + /// Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + PurchaseRefunded('purchase-refunded'), + /// iOS-only: App Store requests a consumption status report for a refund decision. + /// Servers should respond via the StoreKit consumption API. + PurchaseConsumptionRequest('purchase-consumption-request'), + /// Sandbox or test notification fired by the store for diagnostic purposes. + /// Useful for verifying webhook plumbing without a live transaction. + TestNotification('test-notification'); + + const WebhookEventType(this.value); + final String value; + + factory WebhookEventType.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'subscription-started': + return WebhookEventType.SubscriptionStarted; + case 'subscription-renewed': + return WebhookEventType.SubscriptionRenewed; + case 'subscription-expired': + return WebhookEventType.SubscriptionExpired; + case 'subscription-in-grace-period': + return WebhookEventType.SubscriptionInGracePeriod; + case 'subscription-in-billing-retry': + return WebhookEventType.SubscriptionInBillingRetry; + case 'subscription-recovered': + return WebhookEventType.SubscriptionRecovered; + case 'subscription-canceled': + return WebhookEventType.SubscriptionCanceled; + case 'subscription-uncanceled': + return WebhookEventType.SubscriptionUncanceled; + case 'subscription-revoked': + return WebhookEventType.SubscriptionRevoked; + case 'subscription-price-change': + return WebhookEventType.SubscriptionPriceChange; + case 'subscription-product-changed': + return WebhookEventType.SubscriptionProductChanged; + case 'subscription-paused': + return WebhookEventType.SubscriptionPaused; + case 'subscription-resumed': + return WebhookEventType.SubscriptionResumed; + case 'purchase-refunded': + return WebhookEventType.PurchaseRefunded; + case 'purchase-consumption-request': + return WebhookEventType.PurchaseConsumptionRequest; + case 'test-notification': + return WebhookEventType.TestNotification; + } + throw ArgumentError('Unknown WebhookEventType value: $value'); + } + + String toJson() => value; +} + // MARK: - Interfaces abstract class ProductCommon { @@ -3689,6 +3928,114 @@ class VerifyPurchaseWithProviderResult { typedef VoidResult = void; +class WebhookEvent { + const WebhookEvent({ + this.cancellationReason, + this.currency, + required this.environment, + this.expiresAt, + required this.id, + required this.occurredAt, + required this.platform, + this.priceAmountMicros, + this.productId, + required this.projectId, + this.purchaseToken, + this.rawSignedPayload, + required this.receivedAt, + this.renewsAt, + required this.source, + this.subscriptionState, + required this.type, + }); + + /// Reason for cancellation, when applicable. + final WebhookCancellationReason? cancellationReason; + /// Localized currency code (ISO 4217) at event time, when available. + final String? currency; + final WebhookEventEnvironment environment; + /// When the current subscription period ends. Epoch milliseconds. + final double? expiresAt; + /// Stable identifier suitable for idempotency. Derived from the source notification + /// UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + /// otherwise hashed from the canonicalized payload. + final String id; + /// Time the underlying event occurred at the store. Epoch milliseconds. + final double occurredAt; + final IapPlatform platform; + /// Price in micros (1/1,000,000 of the currency unit) at event time, when available. + /// Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + final double? priceAmountMicros; + /// Product the event pertains to. May be null for account-level events. + final String? productId; + /// kit project that owns the subscription / purchase this event refers to. + final String projectId; + /// Cross-platform purchase identity used to correlate this event with an existing + /// purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + /// Null for `TestNotification` events (Apple ASN v2 / Google RTDN test + /// payloads carry no transaction); always present for every other event type. + final String? purchaseToken; + /// Original signed payload from the store. ASN v2 events expose the JWS string; + /// RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + /// consumers can independently verify or extract platform-specific fields. kit + /// always validates this payload before emitting the event. + final String? rawSignedPayload; + /// Time kit ingested and normalized this event. Epoch milliseconds. + final double receivedAt; + /// When auto-renewal will charge again. Epoch milliseconds. + final double? renewsAt; + final WebhookEventSource source; + /// Normalized subscription state at the time of event, when the event refers to + /// a subscription. Null for one-time purchase events. + final SubscriptionState? subscriptionState; + final WebhookEventType type; + + factory WebhookEvent.fromJson(Map json) { + return WebhookEvent( + cancellationReason: json['cancellationReason'] != null ? WebhookCancellationReason.fromJson(json['cancellationReason'] as String) : null, + currency: json['currency'] as String?, + environment: WebhookEventEnvironment.fromJson(json['environment'] as String), + expiresAt: (json['expiresAt'] as num?)?.toDouble(), + id: json['id'] as String, + occurredAt: (json['occurredAt'] as num).toDouble(), + platform: IapPlatform.fromJson(json['platform'] as String), + priceAmountMicros: (json['priceAmountMicros'] as num?)?.toDouble(), + productId: json['productId'] as String?, + projectId: json['projectId'] as String, + purchaseToken: json['purchaseToken'] as String?, + rawSignedPayload: json['rawSignedPayload'] as String?, + receivedAt: (json['receivedAt'] as num).toDouble(), + renewsAt: (json['renewsAt'] as num?)?.toDouble(), + source: WebhookEventSource.fromJson(json['source'] as String), + subscriptionState: json['subscriptionState'] != null ? SubscriptionState.fromJson(json['subscriptionState'] as String) : null, + type: WebhookEventType.fromJson(json['type'] as String), + ); + } + + Map toJson() { + return { + '__typename': 'WebhookEvent', + 'cancellationReason': cancellationReason?.toJson(), + 'currency': currency, + 'environment': environment.toJson(), + 'expiresAt': expiresAt, + 'id': id, + 'occurredAt': occurredAt, + 'platform': platform.toJson(), + 'priceAmountMicros': priceAmountMicros, + 'productId': productId, + 'projectId': projectId, + 'purchaseToken': purchaseToken, + 'rawSignedPayload': rawSignedPayload, + 'receivedAt': receivedAt, + 'renewsAt': renewsAt, + 'source': source.toJson(), + 'subscriptionState': subscriptionState?.toJson(), + 'type': type.toJson(), + }; + } +} + // MARK: - Input Objects class AndroidSubscriptionOfferInput { diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index bc96928f..416cd5fb 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -295,6 +295,74 @@ enum SubscriptionReplacementModeAndroid { KEEP_EXISTING = 6, } +enum SubscriptionState { + ACTIVE = 0, + IN_GRACE_PERIOD = 1, + IN_BILLING_RETRY = 2, + EXPIRED = 3, + REVOKED = 4, + REFUNDED = 5, + PAUSED = 6, + UNKNOWN = 7, +} + +enum WebhookCancellationReason { + USER_CANCELED = 0, + BILLING_ERROR = 1, + PRICE_INCREASE_DECLINED = 2, + PRODUCT_UNAVAILABLE = 3, + REFUNDED = 4, + OTHER = 5, +} + +enum WebhookEventEnvironment { + PRODUCTION = 0, + SANDBOX = 1, + XCODE = 2, +} + +enum WebhookEventSource { + APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2 = 0, + GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS = 1, + ## Synthetic source for Meta Horizon Store. Meta has no webhook / push notification system so kit polls `verify_entitlement` on a cron and emits these synthetic events when an entitlement transitions. SDK consumers see them on the SSE stream alongside real Apple / Google webhooks. + META_HORIZON_RECONCILER = 2, +} + +enum WebhookEventType { + ## Initial purchase or first conversion from a free trial / intro offer. iOS: SUBSCRIBED (initialBuy / resubscribe). Android: SUBSCRIPTION_PURCHASED. + SUBSCRIPTION_STARTED = 0, + ## Auto-renewal succeeded for an existing subscription. iOS: DID_RENEW. Android: SUBSCRIPTION_RENEWED. + SUBSCRIPTION_RENEWED = 1, + ## Subscription reached its expiration without a successful renewal. iOS: EXPIRED. Android: SUBSCRIPTION_EXPIRED. + SUBSCRIPTION_EXPIRED = 2, + ## Billing failed; the subscription is in a grace period during which the user retains entitlement while payment is retried. iOS: DID_FAIL_TO_RENEW (with grace period active). Android: SUBSCRIPTION_IN_GRACE_PERIOD. + SUBSCRIPTION_IN_GRACE_PERIOD = 3, + ## Billing failed and the subscription is in account-hold / billing retry, during which entitlement is paused but the subscription is not yet expired. iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). Android: SUBSCRIPTION_ON_HOLD. + SUBSCRIPTION_IN_BILLING_RETRY = 4, + ## Subscription returned to active state after a billing issue or pause. iOS: DID_RECOVER. Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- renew re-enabled (Uncanceled), not billing recovery. + SUBSCRIPTION_RECOVERED = 5, + ## User turned off auto-renew. Access continues until the current period ends. iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). Android: SUBSCRIPTION_CANCELED. + SUBSCRIPTION_CANCELED = 6, + ## User reactivated auto-renew before the subscription expired. iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + SUBSCRIPTION_UNCANCELED = 7, + ## Access immediately revoked (family sharing removal, admin action, fraud). iOS: REVOKE. Android: SUBSCRIPTION_REVOKED. + SUBSCRIPTION_REVOKED = 8, + ## A price change is pending or has been confirmed by the user. iOS: PRICE_INCREASE. Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + SUBSCRIPTION_PRICE_CHANGE = 9, + ## User upgraded, downgraded, or crossgraded their plan. iOS: DID_CHANGE_RENEWAL_PREF. Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + SUBSCRIPTION_PRODUCT_CHANGED = 10, + ## Subscription paused (Android only feature). Also fired when the pause schedule is changed — RTDN does not have a separate signal. Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). + SUBSCRIPTION_PAUSED = 11, + ## Paused subscription resumed (Android only feature). RTDN signals resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the resume. Android: SUBSCRIPTION_RECOVERED (after pause). + SUBSCRIPTION_RESUMED = 12, + ## Refund issued for a one-time purchase or subscription period. iOS: REFUND. Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + PURCHASE_REFUNDED = 13, + ## iOS-only: App Store requests a consumption status report for a refund decision. Servers should respond via the StoreKit consumption API. + PURCHASE_CONSUMPTION_REQUEST = 14, + ## Sandbox or test notification fired by the store for diagnostic purposes. Useful for verifying webhook plumbing without a live transaction. + TEST_NOTIFICATION = 15, +} + # ============================================================================ # Types # ============================================================================ @@ -3238,6 +3306,146 @@ class VoidResult: dict["success"] = success return dict +class WebhookEvent: + ## Stable identifier suitable for idempotency. Derived from the source notification + var id: String = "" + var type: WebhookEventType + var source: WebhookEventSource + var platform: IapPlatform + ## kit project that owns the subscription / purchase this event refers to. + var project_id: String = "" + ## Time the underlying event occurred at the store. Epoch milliseconds. + var occurred_at: float = 0.0 + ## Time kit ingested and normalized this event. Epoch milliseconds. + var received_at: float = 0.0 + var environment: WebhookEventEnvironment + ## Cross-platform purchase identity used to correlate this event with an existing + var purchase_token: Variant = null + ## Product the event pertains to. May be null for account-level events. + var product_id: Variant = null + ## Normalized subscription state at the time of event, when the event refers to + var subscription_state: SubscriptionState + ## When the current subscription period ends. Epoch milliseconds. + var expires_at: Variant = null + ## When auto-renewal will charge again. Epoch milliseconds. + var renews_at: Variant = null + ## Reason for cancellation, when applicable. + var cancellation_reason: WebhookCancellationReason + ## Localized currency code (ISO 4217) at event time, when available. + var currency: Variant = null + ## Price in micros (1/1,000,000 of the currency unit) at event time, when available. + var price_amount_micros: Variant = null + ## Original signed payload from the store. ASN v2 events expose the JWS string; + var raw_signed_payload: Variant = null + + static func from_dict(data: Dictionary) -> WebhookEvent: + var obj = WebhookEvent.new() + if data.has("id") and data["id"] != null: + obj.id = data["id"] + if data.has("type") and data["type"] != null: + var enum_str = data["type"] + if enum_str is String and WEBHOOK_EVENT_TYPE_FROM_STRING.has(enum_str): + obj.type = WEBHOOK_EVENT_TYPE_FROM_STRING[enum_str] + else: + obj.type = enum_str + if data.has("source") and data["source"] != null: + var enum_str = data["source"] + if enum_str is String and WEBHOOK_EVENT_SOURCE_FROM_STRING.has(enum_str): + obj.source = WEBHOOK_EVENT_SOURCE_FROM_STRING[enum_str] + else: + obj.source = enum_str + if data.has("platform") and data["platform"] != null: + var enum_str = data["platform"] + if enum_str is String and IAP_PLATFORM_FROM_STRING.has(enum_str): + obj.platform = IAP_PLATFORM_FROM_STRING[enum_str] + else: + obj.platform = enum_str + if data.has("projectId") and data["projectId"] != null: + obj.project_id = data["projectId"] + if data.has("occurredAt") and data["occurredAt"] != null: + obj.occurred_at = data["occurredAt"] + if data.has("receivedAt") and data["receivedAt"] != null: + obj.received_at = data["receivedAt"] + if data.has("environment") and data["environment"] != null: + var enum_str = data["environment"] + if enum_str is String and WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING.has(enum_str): + obj.environment = WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING[enum_str] + else: + obj.environment = enum_str + if data.has("purchaseToken") and data["purchaseToken"] != null: + obj.purchase_token = data["purchaseToken"] + if data.has("productId") and data["productId"] != null: + obj.product_id = data["productId"] + if data.has("subscriptionState") and data["subscriptionState"] != null: + var enum_str = data["subscriptionState"] + if enum_str is String and SUBSCRIPTION_STATE_FROM_STRING.has(enum_str): + obj.subscription_state = SUBSCRIPTION_STATE_FROM_STRING[enum_str] + else: + obj.subscription_state = enum_str + if data.has("expiresAt") and data["expiresAt"] != null: + obj.expires_at = data["expiresAt"] + if data.has("renewsAt") and data["renewsAt"] != null: + obj.renews_at = data["renewsAt"] + if data.has("cancellationReason") and data["cancellationReason"] != null: + var enum_str = data["cancellationReason"] + if enum_str is String and WEBHOOK_CANCELLATION_REASON_FROM_STRING.has(enum_str): + obj.cancellation_reason = WEBHOOK_CANCELLATION_REASON_FROM_STRING[enum_str] + else: + obj.cancellation_reason = enum_str + if data.has("currency") and data["currency"] != null: + obj.currency = data["currency"] + if data.has("priceAmountMicros") and data["priceAmountMicros"] != null: + obj.price_amount_micros = data["priceAmountMicros"] + if data.has("rawSignedPayload") and data["rawSignedPayload"] != null: + obj.raw_signed_payload = data["rawSignedPayload"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["id"] = id + if WEBHOOK_EVENT_TYPE_VALUES.has(type): + dict["type"] = WEBHOOK_EVENT_TYPE_VALUES[type] + else: + dict["type"] = type + if WEBHOOK_EVENT_SOURCE_VALUES.has(source): + dict["source"] = WEBHOOK_EVENT_SOURCE_VALUES[source] + else: + dict["source"] = source + if IAP_PLATFORM_VALUES.has(platform): + dict["platform"] = IAP_PLATFORM_VALUES[platform] + else: + dict["platform"] = platform + dict["projectId"] = project_id + dict["occurredAt"] = occurred_at + dict["receivedAt"] = received_at + if WEBHOOK_EVENT_ENVIRONMENT_VALUES.has(environment): + dict["environment"] = WEBHOOK_EVENT_ENVIRONMENT_VALUES[environment] + else: + dict["environment"] = environment + if purchase_token != null: + dict["purchaseToken"] = purchase_token + if product_id != null: + dict["productId"] = product_id + if SUBSCRIPTION_STATE_VALUES.has(subscription_state): + dict["subscriptionState"] = SUBSCRIPTION_STATE_VALUES[subscription_state] + else: + dict["subscriptionState"] = subscription_state + if expires_at != null: + dict["expiresAt"] = expires_at + if renews_at != null: + dict["renewsAt"] = renews_at + if WEBHOOK_CANCELLATION_REASON_VALUES.has(cancellation_reason): + dict["cancellationReason"] = WEBHOOK_CANCELLATION_REASON_VALUES[cancellation_reason] + else: + dict["cancellationReason"] = cancellation_reason + if currency != null: + dict["currency"] = currency + if price_amount_micros != null: + dict["priceAmountMicros"] = price_amount_micros + if raw_signed_payload != null: + dict["rawSignedPayload"] = raw_signed_payload + return dict + # ============================================================================ # Input Types # ============================================================================ @@ -4569,6 +4777,57 @@ const SUBSCRIPTION_REPLACEMENT_MODE_ANDROID_VALUES = { SubscriptionReplacementModeAndroid.KEEP_EXISTING: "keep-existing" } +const SUBSCRIPTION_STATE_VALUES = { + SubscriptionState.ACTIVE: "active", + SubscriptionState.IN_GRACE_PERIOD: "in-grace-period", + SubscriptionState.IN_BILLING_RETRY: "in-billing-retry", + SubscriptionState.EXPIRED: "expired", + SubscriptionState.REVOKED: "revoked", + SubscriptionState.REFUNDED: "refunded", + SubscriptionState.PAUSED: "paused", + SubscriptionState.UNKNOWN: "unknown" +} + +const WEBHOOK_CANCELLATION_REASON_VALUES = { + WebhookCancellationReason.USER_CANCELED: "user-canceled", + WebhookCancellationReason.BILLING_ERROR: "billing-error", + WebhookCancellationReason.PRICE_INCREASE_DECLINED: "price-increase-declined", + WebhookCancellationReason.PRODUCT_UNAVAILABLE: "product-unavailable", + WebhookCancellationReason.REFUNDED: "refunded", + WebhookCancellationReason.OTHER: "other" +} + +const WEBHOOK_EVENT_ENVIRONMENT_VALUES = { + WebhookEventEnvironment.PRODUCTION: "production", + WebhookEventEnvironment.SANDBOX: "sandbox", + WebhookEventEnvironment.XCODE: "xcode" +} + +const WEBHOOK_EVENT_SOURCE_VALUES = { + WebhookEventSource.APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2: "apple-app-store-server-notifications-v2", + WebhookEventSource.GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS: "google-play-real-time-developer-notifications", + WebhookEventSource.META_HORIZON_RECONCILER: "meta-horizon-reconciler" +} + +const WEBHOOK_EVENT_TYPE_VALUES = { + WebhookEventType.SUBSCRIPTION_STARTED: "subscription-started", + WebhookEventType.SUBSCRIPTION_RENEWED: "subscription-renewed", + WebhookEventType.SUBSCRIPTION_EXPIRED: "subscription-expired", + WebhookEventType.SUBSCRIPTION_IN_GRACE_PERIOD: "subscription-in-grace-period", + WebhookEventType.SUBSCRIPTION_IN_BILLING_RETRY: "subscription-in-billing-retry", + WebhookEventType.SUBSCRIPTION_RECOVERED: "subscription-recovered", + WebhookEventType.SUBSCRIPTION_CANCELED: "subscription-canceled", + WebhookEventType.SUBSCRIPTION_UNCANCELED: "subscription-uncanceled", + WebhookEventType.SUBSCRIPTION_REVOKED: "subscription-revoked", + WebhookEventType.SUBSCRIPTION_PRICE_CHANGE: "subscription-price-change", + WebhookEventType.SUBSCRIPTION_PRODUCT_CHANGED: "subscription-product-changed", + WebhookEventType.SUBSCRIPTION_PAUSED: "subscription-paused", + WebhookEventType.SUBSCRIPTION_RESUMED: "subscription-resumed", + WebhookEventType.PURCHASE_REFUNDED: "purchase-refunded", + WebhookEventType.PURCHASE_CONSUMPTION_REQUEST: "purchase-consumption-request", + WebhookEventType.TEST_NOTIFICATION: "test-notification" +} + # ============================================================================ # Enum Reverse Lookup (string -> enum for deserialization) # ============================================================================ @@ -4787,6 +5046,57 @@ const SUBSCRIPTION_REPLACEMENT_MODE_ANDROID_FROM_STRING = { "keep-existing": SubscriptionReplacementModeAndroid.KEEP_EXISTING } +const SUBSCRIPTION_STATE_FROM_STRING = { + "active": SubscriptionState.ACTIVE, + "in-grace-period": SubscriptionState.IN_GRACE_PERIOD, + "in-billing-retry": SubscriptionState.IN_BILLING_RETRY, + "expired": SubscriptionState.EXPIRED, + "revoked": SubscriptionState.REVOKED, + "refunded": SubscriptionState.REFUNDED, + "paused": SubscriptionState.PAUSED, + "unknown": SubscriptionState.UNKNOWN +} + +const WEBHOOK_CANCELLATION_REASON_FROM_STRING = { + "user-canceled": WebhookCancellationReason.USER_CANCELED, + "billing-error": WebhookCancellationReason.BILLING_ERROR, + "price-increase-declined": WebhookCancellationReason.PRICE_INCREASE_DECLINED, + "product-unavailable": WebhookCancellationReason.PRODUCT_UNAVAILABLE, + "refunded": WebhookCancellationReason.REFUNDED, + "other": WebhookCancellationReason.OTHER +} + +const WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING = { + "production": WebhookEventEnvironment.PRODUCTION, + "sandbox": WebhookEventEnvironment.SANDBOX, + "xcode": WebhookEventEnvironment.XCODE +} + +const WEBHOOK_EVENT_SOURCE_FROM_STRING = { + "apple-app-store-server-notifications-v2": WebhookEventSource.APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2, + "google-play-real-time-developer-notifications": WebhookEventSource.GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS, + "meta-horizon-reconciler": WebhookEventSource.META_HORIZON_RECONCILER +} + +const WEBHOOK_EVENT_TYPE_FROM_STRING = { + "subscription-started": WebhookEventType.SUBSCRIPTION_STARTED, + "subscription-renewed": WebhookEventType.SUBSCRIPTION_RENEWED, + "subscription-expired": WebhookEventType.SUBSCRIPTION_EXPIRED, + "subscription-in-grace-period": WebhookEventType.SUBSCRIPTION_IN_GRACE_PERIOD, + "subscription-in-billing-retry": WebhookEventType.SUBSCRIPTION_IN_BILLING_RETRY, + "subscription-recovered": WebhookEventType.SUBSCRIPTION_RECOVERED, + "subscription-canceled": WebhookEventType.SUBSCRIPTION_CANCELED, + "subscription-uncanceled": WebhookEventType.SUBSCRIPTION_UNCANCELED, + "subscription-revoked": WebhookEventType.SUBSCRIPTION_REVOKED, + "subscription-price-change": WebhookEventType.SUBSCRIPTION_PRICE_CHANGE, + "subscription-product-changed": WebhookEventType.SUBSCRIPTION_PRODUCT_CHANGED, + "subscription-paused": WebhookEventType.SUBSCRIPTION_PAUSED, + "subscription-resumed": WebhookEventType.SUBSCRIPTION_RESUMED, + "purchase-refunded": WebhookEventType.PURCHASE_REFUNDED, + "purchase-consumption-request": WebhookEventType.PURCHASE_CONSUMPTION_REQUEST, + "test-notification": WebhookEventType.TEST_NOTIFICATION +} + # ============================================================================ # Query Types # ============================================================================ diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 48f0103b..2647f51b 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -1896,6 +1896,8 @@ export interface SubscriptionProductReplacementParamsAndroid { */ export type SubscriptionReplacementModeAndroid = 'unknown-replacement-mode' | 'with-time-proration' | 'charge-prorated-price' | 'charge-full-price' | 'without-proration' | 'deferred' | 'keep-existing'; +export type SubscriptionState = 'active' | 'expired' | 'in-billing-retry' | 'in-grace-period' | 'paused' | 'refunded' | 'revoked' | 'unknown'; + export interface SubscriptionStatusIOS { renewalInfo?: (RenewalInfoIOS | null); state: string; @@ -2057,6 +2059,67 @@ export interface VerifyPurchaseWithProviderResult { export type VoidResult = void; +export type WebhookCancellationReason = 'billing-error' | 'other' | 'price-increase-declined' | 'product-unavailable' | 'refunded' | 'user-canceled'; + +export interface WebhookEvent { + /** Reason for cancellation, when applicable. */ + cancellationReason?: (WebhookCancellationReason | null); + /** Localized currency code (ISO 4217) at event time, when available. */ + currency?: (string | null); + environment: WebhookEventEnvironment; + /** When the current subscription period ends. Epoch milliseconds. */ + expiresAt?: (number | null); + /** + * Stable identifier suitable for idempotency. Derived from the source notification + * UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + * otherwise hashed from the canonicalized payload. + */ + id: string; + /** Time the underlying event occurred at the store. Epoch milliseconds. */ + occurredAt: number; + platform: IapPlatform; + /** + * Price in micros (1/1,000,000 of the currency unit) at event time, when available. + * Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + */ + priceAmountMicros?: (number | null); + /** Product the event pertains to. May be null for account-level events. */ + productId?: (string | null); + /** kit project that owns the subscription / purchase this event refers to. */ + projectId: string; + /** + * Cross-platform purchase identity used to correlate this event with an existing + * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + * Null for `TestNotification` events (Apple ASN v2 / Google RTDN test + * payloads carry no transaction); always present for every other event type. + */ + purchaseToken?: (string | null); + /** + * Original signed payload from the store. ASN v2 events expose the JWS string; + * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + * consumers can independently verify or extract platform-specific fields. kit + * always validates this payload before emitting the event. + */ + rawSignedPayload?: (string | null); + /** Time kit ingested and normalized this event. Epoch milliseconds. */ + receivedAt: number; + /** When auto-renewal will charge again. Epoch milliseconds. */ + renewsAt?: (number | null); + source: WebhookEventSource; + /** + * Normalized subscription state at the time of event, when the event refers to + * a subscription. Null for one-time purchase events. + */ + subscriptionState?: (SubscriptionState | null); + type: WebhookEventType; +} + +export type WebhookEventEnvironment = 'production' | 'sandbox' | 'xcode'; + +export type WebhookEventSource = 'apple-app-store-server-notifications-v2' | 'google-play-real-time-developer-notifications' | 'meta-horizon-reconciler'; + +export type WebhookEventType = 'purchase-consumption-request' | 'purchase-refunded' | 'subscription-canceled' | 'subscription-expired' | 'subscription-in-billing-retry' | 'subscription-in-grace-period' | 'subscription-paused' | 'subscription-price-change' | 'subscription-product-changed' | 'subscription-recovered' | 'subscription-renewed' | 'subscription-resumed' | 'subscription-revoked' | 'subscription-started' | 'subscription-uncanceled' | 'test-notification'; + /** * Win-back offer input for iOS 18+ (StoreKit 2) * Win-back offers are used to re-engage churned subscribers. diff --git a/packages/gql/src/kit-api.test.ts b/packages/gql/src/kit-api.test.ts new file mode 100644 index 00000000..4e98eaf9 --- /dev/null +++ b/packages/gql/src/kit-api.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from "vitest"; +import { kitApi, KitApiError } from "./kit-api"; + +function fakeFetch( + recipe: ( + path: string, + init?: RequestInit, + ) => { + status: number; + body: unknown; + }, +) { + return vi.fn(async (input: string, init?: RequestInit) => { + const url = new URL(input); + const path = url.pathname + url.search; + const { status, body } = recipe(path, init); + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(), + text: async () => + typeof body === "string" ? body : JSON.stringify(body), + } as unknown as Response; + }); +} + +describe("kitApi", () => { + it("calls /v1/subscriptions/status with the apiKey + userId", async () => { + const fetchImpl = fakeFetch(() => ({ + status: 200, + body: { active: true, subscription: null }, + })); + const api = kitApi({ + apiKey: "k", + baseUrl: "http://localhost", + fetchImpl: fetchImpl as never, + }); + const result = await api.status("user-1"); + expect(result.active).toBe(true); + expect(fetchImpl).toHaveBeenCalledWith( + "http://localhost/v1/subscriptions/status/k?userId=user-1", + expect.anything(), + ); + }); + + it("URL-encodes apiKey and userId", async () => { + const fetchImpl = fakeFetch(() => ({ + status: 200, + body: { userId: "u 1", productIds: [], subscriptions: [] }, + })); + const api = kitApi({ + apiKey: "k 1", + baseUrl: "http://localhost", + fetchImpl: fetchImpl as never, + }); + await api.entitlements("u 1"); + expect(fetchImpl).toHaveBeenCalledWith( + "http://localhost/v1/subscriptions/entitlements/k%201?userId=u%201", + expect.anything(), + ); + }); + + it("throws KitApiError on non-2xx", async () => { + const fetchImpl = fakeFetch(() => ({ + status: 401, + body: { errors: [{ code: "INVALID_API_KEY", message: "nope" }] }, + })); + const api = kitApi({ + apiKey: "bad", + baseUrl: "http://localhost", + fetchImpl: fetchImpl as never, + }); + await expect(api.status("u")).rejects.toBeInstanceOf(KitApiError); + }); + + it("bindUser POSTs JSON", async () => { + const fetchImpl = fakeFetch((_path, init) => { + expect(init?.method).toBe("POST"); + const body = JSON.parse(init?.body as string); + expect(body).toEqual({ purchaseToken: "tok", userId: "user" }); + return { status: 200, body: { ok: true, bound: true } }; + }); + const api = kitApi({ + apiKey: "k", + baseUrl: "http://localhost", + fetchImpl: fetchImpl as never, + }); + const result = await api.bindUser("tok", "user"); + expect(result).toEqual({ ok: true, bound: true }); + }); +}); diff --git a/packages/gql/src/kit-api.ts b/packages/gql/src/kit-api.ts new file mode 100644 index 00000000..72f68247 --- /dev/null +++ b/packages/gql/src/kit-api.ts @@ -0,0 +1,225 @@ +// Tiny fetch wrapper around kit's `/v1` HTTP surface for use by the JS +// SDK consumers (react-native-iap + expo-iap). Mirrors the shape of +// `packages/mcp-server/src/kit-client.ts` so the same operations are +// reachable from both LLM tools and end-user apps without each +// duplicating the URL layout. + +export type KitApiOptions = { + apiKey: string; + baseUrl?: string; + // Optional fetch override for runtimes without a global (older RN + // builds) or for injection in tests. + fetchImpl?: (input: string, init?: RequestInit) => Promise; +}; + +export type KitSubscription = { + id: string; + productId: string; + platform: "IOS" | "Android"; + state: string; + expiresAt?: number; + renewsAt?: number; + willRenew?: boolean; + cancellationReason?: string; + currency?: string; + priceAmountMicros?: number; + startedAt: number; + updatedAt: number; + purchaseToken: string; + userId?: string; +}; + +export type EntitlementsResponse = { + userId: string; + productIds: string[]; + subscriptions: KitSubscription[]; +}; + +export type StatusResponse = { + active: boolean; + subscription: KitSubscription | null; +}; + +const DEFAULT_BASE_URL = "https://kit.openiap.dev"; + +// Merge caller-supplied headers with kit defaults (`accept`, +// optionally `content-type`). When the runtime exposes a global +// `Headers` constructor we use it directly so callers passing a +// `Headers` instance (a `HeadersInit`) keep that exact instance's +// values. When `Headers` is missing — older React Native builds where +// the operator wires up `fetchImpl` without a `Headers` polyfill — +// we fall back to a case-insensitive merge into a plain record so +// the request still goes through. Either way, caller-set values take +// precedence over kit defaults. +function mergeHeaders( + callerHeaders: HeadersInit | undefined, + hasBody: boolean, +): HeadersInit { + if (typeof Headers === "function") { + const merged = new Headers(callerHeaders); + if (!merged.has("accept")) merged.set("accept", "application/json"); + if (hasBody && !merged.has("content-type")) { + merged.set("content-type", "application/json"); + } + return merged; + } + // Plain-object fallback path. Build a case-insensitive name map + // from whatever the caller passed (Headers-shaped, array-of-pairs, + // or plain record) and re-emit as a record `fetchImpl` accepts. + const lower = new Map(); + const setIfAbsent = (name: string, value: string) => { + const key = name.toLowerCase(); + if (!lower.has(key)) lower.set(key, { name, value }); + }; + const setForce = (name: string, value: string) => { + const key = name.toLowerCase(); + lower.set(key, { name, value }); + }; + if (callerHeaders) { + if (Array.isArray(callerHeaders)) { + for (const [name, value] of callerHeaders) setForce(name, value); + } else if ( + typeof (callerHeaders as { forEach?: unknown }).forEach === "function" + ) { + // `Headers`-like (without being our `typeof Headers === "function"` + // global). RN polyfills sometimes attach `Headers` only to + // request/response instances rather than the global scope. + // Standard signature is `forEach((value, key, parent))`; we + // bind the first two positionally so a polyfill that omits + // the third argument still works. `key` is the header name. + ( + callerHeaders as { + forEach: (cb: (value: string, key: string) => void) => void; + } + ).forEach((value, key) => setForce(key, value)); + } else { + for (const [name, value] of Object.entries( + callerHeaders as Record, + )) { + setForce(name, value); + } + } + } + setIfAbsent("accept", "application/json"); + if (hasBody) setIfAbsent("content-type", "application/json"); + const out: Record = {}; + for (const { name, value } of lower.values()) out[name] = value; + return out; +} + +export class KitApiError extends Error { + constructor( + readonly status: number, + readonly body: unknown, + message: string, + ) { + super(message); + this.name = "KitApiError"; + } +} + +export function kitApi(options: KitApiOptions) { + const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ""); + const fetchImpl: (input: string, init?: RequestInit) => Promise = + (() => { + if (options.fetchImpl) return options.fetchImpl; + if (typeof fetch === "function") { + return (input: string, init?: RequestInit) => fetch(input, init); + } + throw new Error( + "kitApi requires a fetch implementation. Pass `fetchImpl` for runtimes without a global fetch.", + ); + })(); + + async function call(path: string, init?: RequestInit): Promise { + // Normalize headers without depending on a global `Headers` + // constructor: older React Native runtimes ship `fetch` (or a + // polyfill via `fetchImpl`) without exposing `Headers` globally. + // The prior implementation crashed before the first request on + // those runtimes. We use `new Headers()` when available (preserves + // caller-supplied `Headers` instances exactly), and otherwise fall + // back to a small case-insensitive merge into a plain record. + // Either way, kit defaults only apply when the caller hasn't set + // the same name. + const headers = mergeHeaders(init?.headers, init?.body != null); + // Prepend a leading slash if `path` is missing one. Today's + // call sites all hard-code the leading "/", but normalizing here + // makes the helper safe for future additions and matches the + // already-stripped `baseUrl` (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const response = await fetchImpl(`${baseUrl}${normalizedPath}`, { + ...init, + headers, + }); + const text = await response.text(); + // Empty body normalizes to null so callers expecting JSON + // (status / entitlements / list*) don't get a truthy "" + // and crash on property access. + let parsed: unknown = null; + let parseError: unknown = null; + if (text) { + try { + parsed = JSON.parse(text); + } catch (error) { + // Non-JSON body (a misconfigured proxy returning HTML, a + // CDN-injected error page, etc.) on a 2xx response would + // otherwise reach the caller as `parsed = text` and crash + // on property access via `parsed as T`. Throw a structured + // KitApiError instead so callers see a typed failure. + parseError = error; + } + } + if (!response.ok) { + // Surface the raw body (text or parsed) on the error path so + // operators can read the upstream error message verbatim. + throw new KitApiError( + response.status, + parsed ?? text, + `kit ${path} returned ${response.status}`, + ); + } + if (parseError) { + throw new KitApiError( + response.status, + text, + `kit ${path} returned a non-JSON ${response.status} body (${ + parseError instanceof Error ? parseError.message : String(parseError) + })`, + ); + } + return parsed as T; + } + + return { + apiKey: options.apiKey, + baseUrl, + + /** GET /v1/subscriptions/status — the `active` boolean is the + * fastest gate for "is this user paying?". */ + status: (userId: string) => + call( + `/v1/subscriptions/status/${encodeURIComponent(options.apiKey)}?userId=${encodeURIComponent(userId)}`, + ), + + /** GET /v1/subscriptions/entitlements — every productId the user + * is entitled to. Use this when feature gating depends on which + * specific tier the user owns. */ + entitlements: (userId: string) => + call( + `/v1/subscriptions/entitlements/${encodeURIComponent(options.apiKey)}?userId=${encodeURIComponent(userId)}`, + ), + + /** POST /v1/subscriptions/bind-user — call after a successful + * verifyReceipt so kit knows which userId owns the verified + * `purchaseToken`. Idempotent. */ + bindUser: (purchaseToken: string, userId: string) => + call<{ ok: boolean; bound: boolean }>( + `/v1/subscriptions/bind-user/${encodeURIComponent(options.apiKey)}`, + { + method: "POST", + body: JSON.stringify({ purchaseToken, userId }), + }, + ), + }; +} diff --git a/packages/gql/src/webhook-client.test.ts b/packages/gql/src/webhook-client.test.ts new file mode 100644 index 00000000..b9306bed --- /dev/null +++ b/packages/gql/src/webhook-client.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + WEBHOOK_EVENT_TYPES, + connectWebhookStream, + parseWebhookEventData, + type WebhookEventPayload, + type WebhookEventStream, +} from "./webhook-client"; + +const validEvent: WebhookEventPayload = { + id: "uuid-1", + type: "SubscriptionRenewed", + source: "AppleAppStoreServerNotificationsV2", + platform: "IOS", + environment: "Production", + projectId: "project-1", + occurredAt: 1_711_000_000_000, + receivedAt: 1_711_000_001_000, + purchaseToken: "token-1", + productId: "com.example.premium", + subscriptionState: "Active", +}; + +describe("parseWebhookEventData", () => { + it("parses a valid event JSON into an event payload", () => { + const result = parseWebhookEventData(JSON.stringify(validEvent)); + expect(result.kind).toBe("ok"); + if (result.kind === "ok") { + expect(result.event.id).toBe("uuid-1"); + expect(result.event.type).toBe("SubscriptionRenewed"); + expect(result.event.purchaseToken).toBe("token-1"); + } + }); + + it("skips heartbeats (empty payload)", () => { + expect(parseWebhookEventData("")).toEqual({ + kind: "skip", + reason: "heartbeat", + }); + }); + + it("skips stream-control envelopes that have no `type`", () => { + const result = parseWebhookEventData(JSON.stringify({ cursor: 12345 })); + expect(result.kind).toBe("skip"); + }); + + it("returns parse error on malformed JSON", () => { + const result = parseWebhookEventData("not json"); + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/parse SSE payload/); + } + }); + + it("returns error when required fields are missing", () => { + const result = parseWebhookEventData( + JSON.stringify({ + type: "SubscriptionRenewed", + // missing id / purchaseToken / occurredAt / receivedAt + }), + ); + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/missing required fields/); + } + }); +}); + +describe("connectWebhookStream", () => { + it("subscribes to typed SSE events and forwards events", () => { + const onEvent = vi.fn(); + const onError = vi.fn(); + + const handlers = new Map< + string, + (event: { data: string; lastEventId?: string }) => void + >(); + + const fakeStream: WebhookEventStream = { + onmessage: null, + onerror: null, + addEventListener: (type, listener) => { + handlers.set(type, listener); + }, + close: vi.fn(), + }; + + const factory = vi.fn(() => fakeStream); + + const listener = connectWebhookStream({ + apiKey: "test-key", + baseUrl: "http://localhost:3100", + onEvent, + onError, + eventSourceFactory: factory, + }); + + expect(factory).toHaveBeenCalledWith( + "http://localhost:3100/v1/webhooks/stream/test-key", + {}, + ); + + expect(handlers.get("message")).toBeDefined(); + for (const eventType of WEBHOOK_EVENT_TYPES) { + expect(handlers.get(eventType)).toBeDefined(); + } + handlers.get("SubscriptionRenewed")!({ + data: JSON.stringify(validEvent), + }); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent.mock.calls[0][0].id).toBe("uuid-1"); + expect(onError).not.toHaveBeenCalled(); + + listener.close(); + expect(fakeStream.close).toHaveBeenCalled(); + }); + + it("dedupes when a polyfill dispatches the same frame twice", () => { + const onEvent = vi.fn(); + const handlers = new Map< + string, + (event: { data: string; lastEventId?: string }) => void + >(); + + connectWebhookStream({ + apiKey: "test-key", + baseUrl: "http://localhost:3100", + onEvent, + eventSourceFactory: () => ({ + onmessage: null, + onerror: null, + addEventListener: (type, listener) => { + handlers.set(type, listener); + }, + close: () => {}, + }), + }); + + const data = JSON.stringify(validEvent); + handlers.get("SubscriptionRenewed")!({ data }); + handlers.get("message")!({ data }); + + expect(onEvent).toHaveBeenCalledTimes(1); + }); + + it("falls back to onmessage when addEventListener is not provided", () => { + const onEvent = vi.fn(); + + const fakeStream: WebhookEventStream = { + onmessage: null, + onerror: null, + close: () => {}, + }; + + connectWebhookStream({ + apiKey: "test-key", + baseUrl: "http://localhost:3100", + onEvent, + eventSourceFactory: () => fakeStream, + }); + + expect(fakeStream.onmessage).not.toBeNull(); + fakeStream.onmessage!({ data: JSON.stringify(validEvent) }); + expect(onEvent).toHaveBeenCalledTimes(1); + }); + + it("calls onError with TRANSPORT_ERROR when the stream errors", () => { + const onError = vi.fn(); + const fakeStream: WebhookEventStream = { + onmessage: null, + onerror: null, + close: () => {}, + }; + + connectWebhookStream({ + apiKey: "test-key", + baseUrl: "http://localhost:3100", + onEvent: () => {}, + onError, + eventSourceFactory: () => fakeStream, + }); + + fakeStream.onerror?.(new Error("boom")); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: "TRANSPORT_ERROR" }), + ); + }); + + it("emits NO_EVENTSOURCE when factory throws and returns a no-op listener", () => { + const onError = vi.fn(); + const result = connectWebhookStream({ + apiKey: "key", + baseUrl: "http://localhost", + onEvent: () => {}, + onError, + eventSourceFactory: () => { + throw new Error("missing"); + }, + }); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: "NO_EVENTSOURCE" }), + ); + expect(typeof result.close).toBe("function"); + }); + + it("trims trailing slashes in baseUrl", () => { + const factory = vi.fn( + (): WebhookEventStream => ({ + onmessage: null, + onerror: null, + close: () => {}, + }), + ); + connectWebhookStream({ + apiKey: "k", + baseUrl: "https://kit.openiap.dev/", + onEvent: () => {}, + eventSourceFactory: factory, + }); + expect(factory.mock.calls[0][0]).toBe( + "https://kit.openiap.dev/v1/webhooks/stream/k", + ); + }); + + it("URL-encodes the apiKey", () => { + const factory = vi.fn( + (): WebhookEventStream => ({ + onmessage: null, + onerror: null, + close: () => {}, + }), + ); + connectWebhookStream({ + apiKey: "key with spaces & symbols", + baseUrl: "http://localhost", + onEvent: () => {}, + eventSourceFactory: factory, + }); + expect(factory.mock.calls[0][0]).toBe( + "http://localhost/v1/webhooks/stream/key%20with%20spaces%20%26%20symbols", + ); + }); +}); diff --git a/packages/gql/src/webhook-client.ts b/packages/gql/src/webhook-client.ts new file mode 100644 index 00000000..ad13e90d --- /dev/null +++ b/packages/gql/src/webhook-client.ts @@ -0,0 +1,312 @@ +// Transport-agnostic webhook client for the openiap kit SSE stream +// (`GET /v1/webhooks/stream/{apiKey}`). Used by the JavaScript / TS +// wrappers (react-native-iap, expo-iap) but written without React or +// React-Native imports so it can also run in plain Node, browser, or +// any other JS runtime. +// +// The wire format is documented in `packages/kit/server/api/v1/webhooks.ts` +// and matches the GraphQL `WebhookEvent` shape from `webhook.graphql`. +// +// Parser logic is split out from the connection so it can be unit- +// tested without a live server. See `webhook-client.test.ts`. + +export type WebhookEventType = + | "SubscriptionStarted" + | "SubscriptionRenewed" + | "SubscriptionExpired" + | "SubscriptionInGracePeriod" + | "SubscriptionInBillingRetry" + | "SubscriptionRecovered" + | "SubscriptionCanceled" + | "SubscriptionUncanceled" + | "SubscriptionRevoked" + | "SubscriptionPriceChange" + | "SubscriptionProductChanged" + | "SubscriptionPaused" + | "SubscriptionResumed" + | "PurchaseRefunded" + | "PurchaseConsumptionRequest" + | "TestNotification"; + +export const WEBHOOK_EVENT_TYPES = [ + "SubscriptionStarted", + "SubscriptionRenewed", + "SubscriptionExpired", + "SubscriptionInGracePeriod", + "SubscriptionInBillingRetry", + "SubscriptionRecovered", + "SubscriptionCanceled", + "SubscriptionUncanceled", + "SubscriptionRevoked", + "SubscriptionPriceChange", + "SubscriptionProductChanged", + "SubscriptionPaused", + "SubscriptionResumed", + "PurchaseRefunded", + "PurchaseConsumptionRequest", + "TestNotification", +] as const satisfies readonly WebhookEventType[]; + +export type WebhookEventPayload = { + id: string; + type: WebhookEventType; + source: string; + platform: "IOS" | "Android"; + environment: "Production" | "Sandbox" | "Xcode"; + projectId: string; + occurredAt: number; + receivedAt: number; + // Optional because TestNotification frames carry no transaction; + // every other event type populates this. + purchaseToken?: string; + productId?: string; + subscriptionState?: string; + expiresAt?: number; + renewsAt?: number; + cancellationReason?: string; + currency?: string; + priceAmountMicros?: number; + rawSignedPayload?: string; +}; + +export type WebhookListenerOptions = { + /** + * Project API key. Embedded in the URL path because Apple ASN + * registration cannot send custom headers; the same path is reused + * here for symmetry. + */ + apiKey: string; + /** + * Override the kit base URL. Defaults to https://kit.openiap.dev. + * In tests, point this at a local server. + */ + baseUrl?: string; + /** Called on every successfully-parsed webhook event. */ + onEvent: (event: WebhookEventPayload) => void; + /** + * Called on transport errors. The connection auto-reconnects + * unconditionally; this callback exists for telemetry / surfacing + * to the host UI. + */ + onError?: (error: WebhookListenerError) => void; + /** + * Optional injection of an EventSource constructor. Lets RN / + * Expo plug in `react-native-event-source` when running on a JS + * runtime that lacks the global, or vitest plug in a stub. + */ + eventSourceFactory?: ( + url: string, + headers: Record, + ) => WebhookEventStream; +}; + +export interface WebhookEventStream { + close(): void; + onmessage: ((event: { data: string; lastEventId?: string }) => void) | null; + onerror: ((error: unknown) => void) | null; + addEventListener?: ( + type: string, + listener: (event: { data: string; lastEventId?: string }) => void, + ) => void; +} + +export type WebhookListener = { + /** Tear down the connection and stop receiving events. */ + close(): void; +}; + +export type WebhookListenerError = { + code: + | "TRANSPORT_ERROR" + | "PARSE_ERROR" + | "MALFORMED_EVENT" + | "NO_EVENTSOURCE"; + message: string; + cause?: unknown; +}; + +const DEFAULT_BASE_URL = "https://kit.openiap.dev"; + +export function connectWebhookStream( + options: WebhookListenerOptions, +): WebhookListener { + const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; + const url = `${trimTrailingSlash(baseUrl)}/v1/webhooks/stream/${encodeURIComponent(options.apiKey)}`; + + const factory = options.eventSourceFactory ?? defaultEventSourceFactory; + let stream: WebhookEventStream; + try { + stream = factory(url, {}); + } catch (error) { + options.onError?.({ + code: "NO_EVENTSOURCE", + message: + error instanceof Error + ? error.message + : "EventSource constructor unavailable in this runtime", + cause: error, + }); + return { close: () => {} }; + } + + const seenIds = new Set(); + const seenOrder: string[] = []; + const markSeen = (id: string): boolean => { + if (seenIds.has(id)) { + return true; + } + seenIds.add(id); + seenOrder.push(id); + if (seenOrder.length > 1024) { + const evicted = seenOrder.shift(); + if (evicted !== undefined) { + seenIds.delete(evicted); + } + } + return false; + }; + + const handleData = (raw: string) => { + const parsed = parseWebhookEventData(raw); + if (parsed.kind === "error") { + options.onError?.({ + code: "PARSE_ERROR", + message: parsed.message, + }); + return; + } + if (parsed.kind === "skip") { + return; + } + if (markSeen(parsed.event.id)) { + return; + } + options.onEvent(parsed.event); + }; + + if (typeof stream.addEventListener === "function") { + stream.addEventListener("message", (event) => handleData(event.data)); + // WHATWG EventSource dispatches frames with `event: Foo` only to + // listeners registered for `Foo`, not to `message` / `onmessage`. + // Kit emits webhook frames as typed SSE events, so subscribe to + // every known webhook type and keep `message` for older servers or + // polyfills that collapse typed frames into the generic channel. + for (const eventType of WEBHOOK_EVENT_TYPES) { + stream.addEventListener(eventType, (event) => handleData(event.data)); + } + } else { + stream.onmessage = (event) => handleData(event.data); + } + + stream.onerror = (error) => { + options.onError?.({ + code: "TRANSPORT_ERROR", + message: "SSE transport error (auto-reconnecting)", + cause: error, + }); + }; + + return { + close: () => { + try { + stream.close(); + } catch { + // Closing an already-closed EventSource is a no-op in browsers + // but throws in some polyfills. + } + }, + }; +} + +// --------------------------------------------------------------------------- +// Pure helpers (exported for testing). +// --------------------------------------------------------------------------- + +export type ParsedEventResult = + | { kind: "ok"; event: WebhookEventPayload } + | { kind: "skip"; reason: "heartbeat" | "stream-control" } + | { kind: "error"; message: string }; + +export function parseWebhookEventData(raw: string): ParsedEventResult { + if (!raw) { + return { kind: "skip", reason: "heartbeat" }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + return { + kind: "error", + message: + error instanceof Error + ? `Failed to parse SSE payload: ${error.message}` + : "Failed to parse SSE payload", + }; + } + + if ( + typeof parsed !== "object" || + parsed === null || + !("type" in parsed) || + typeof (parsed as Record).type !== "string" + ) { + // Stream-control messages (the `ready`/`stream-error` envelopes + // emitted by the kit server) have no `type` and are surfaced as + // skips so consumers don't see them as events. + return { kind: "skip", reason: "stream-control" }; + } + + const event = parsed as WebhookEventPayload; + + if ( + typeof event.id !== "string" || + typeof event.occurredAt !== "number" || + typeof event.receivedAt !== "number" + ) { + return { + kind: "error", + message: `WebhookEvent missing required fields (id/occurredAt/receivedAt)`, + }; + } + // purchaseToken is required for every event type *except* + // TestNotification — Apple ASN v2 / Google RTDN test payloads + // carry no transaction. Hard-rejecting here would surface valid + // test webhooks as MALFORMED_EVENT and never reach listeners. + if ( + event.type !== "TestNotification" && + typeof event.purchaseToken !== "string" + ) { + return { + kind: "error", + message: `WebhookEvent missing required field purchaseToken`, + }; + } + + return { kind: "ok", event }; +} + +function trimTrailingSlash(url: string): string { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +function defaultEventSourceFactory( + url: string, + _headers: Record, +): WebhookEventStream { + // EventSource is part of the WHATWG spec and available in all + // browser environments and most JS runtimes (Bun, Node 22+, Deno). + // RN does not ship it natively — consumers must pass + // `eventSourceFactory` from `react-native-sse` or similar. + const ctor = ( + globalThis as { + EventSource?: new (url: string) => WebhookEventStream; + } + ).EventSource; + if (!ctor) { + throw new Error( + "EventSource is not defined. Pass `eventSourceFactory` for runtimes without a built-in EventSource.", + ); + } + return new ctor(url); +} diff --git a/packages/gql/src/webhook.graphql b/packages/gql/src/webhook.graphql new file mode 100644 index 00000000..b52dd660 --- /dev/null +++ b/packages/gql/src/webhook.graphql @@ -0,0 +1,241 @@ +# Server-side lifecycle webhook events normalized across stores. +# +# kit (kit.openiap.dev) ingests Apple App Store Server Notifications v2 (ASN v2) +# and Google Play Real-Time Developer Notifications (RTDN), normalizes them into +# a unified WebhookEvent shape, and streams them to authenticated clients via the +# `webhookEvent` GraphQL Subscription. +# +# Design goals: +# - Single event shape regardless of which store fired the notification. +# - Idempotency: every normalized event has a stable `id` so consumers can dedupe. +# - Escape hatch: the raw signed payload from the store is preserved in +# `rawSignedPayload` for consumers that need platform-specific fields. + +# What kind of lifecycle change occurred. Mapped from ASN v2 notificationType +# and RTDN notificationType to a unified vocabulary. +enum WebhookEventType { + """ + Initial purchase or first conversion from a free trial / intro offer. + iOS: SUBSCRIBED (initialBuy / resubscribe). + Android: SUBSCRIPTION_PURCHASED. + """ + SubscriptionStarted + """ + Auto-renewal succeeded for an existing subscription. + iOS: DID_RENEW. + Android: SUBSCRIPTION_RENEWED. + """ + SubscriptionRenewed + """ + Subscription reached its expiration without a successful renewal. + iOS: EXPIRED. + Android: SUBSCRIPTION_EXPIRED. + """ + SubscriptionExpired + """ + Billing failed; the subscription is in a grace period during which the user + retains entitlement while payment is retried. + iOS: DID_FAIL_TO_RENEW (with grace period active). + Android: SUBSCRIPTION_IN_GRACE_PERIOD. + """ + SubscriptionInGracePeriod + """ + Billing failed and the subscription is in account-hold / billing retry, + during which entitlement is paused but the subscription is not yet expired. + iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + Android: SUBSCRIPTION_ON_HOLD. + """ + SubscriptionInBillingRetry + """ + Subscription returned to active state after a billing issue or pause. + iOS: DID_RECOVER. + Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- + renew re-enabled (Uncanceled), not billing recovery. + """ + SubscriptionRecovered + """ + User turned off auto-renew. Access continues until the current period ends. + iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). + Android: SUBSCRIPTION_CANCELED. + """ + SubscriptionCanceled + """ + User reactivated auto-renew before the subscription expired. + iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). + Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + """ + SubscriptionUncanceled + """ + Access immediately revoked (family sharing removal, admin action, fraud). + iOS: REVOKE. + Android: SUBSCRIPTION_REVOKED. + """ + SubscriptionRevoked + """ + A price change is pending or has been confirmed by the user. + iOS: PRICE_INCREASE. + Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + """ + SubscriptionPriceChange + """ + User upgraded, downgraded, or crossgraded their plan. + iOS: DID_CHANGE_RENEWAL_PREF. + Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + """ + SubscriptionProductChanged + """ + Subscription paused (Android only feature). Also fired when the + pause schedule is changed — RTDN does not have a separate signal. + Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). + """ + SubscriptionPaused + """ + Paused subscription resumed (Android only feature). RTDN signals + resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle + starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the + resume. + Android: SUBSCRIPTION_RECOVERED (after pause). + """ + SubscriptionResumed + """ + Refund issued for a one-time purchase or subscription period. + iOS: REFUND. + Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + """ + PurchaseRefunded + """ + iOS-only: App Store requests a consumption status report for a refund decision. + Servers should respond via the StoreKit consumption API. + """ + PurchaseConsumptionRequest + """ + Sandbox or test notification fired by the store for diagnostic purposes. + Useful for verifying webhook plumbing without a live transaction. + """ + TestNotification +} + +# Which store-side notification system produced this event. +enum WebhookEventSource { + AppleAppStoreServerNotificationsV2 + GooglePlayRealTimeDeveloperNotifications + """ + Synthetic source for Meta Horizon Store. Meta has no webhook / + push notification system so kit polls `verify_entitlement` on a + cron and emits these synthetic events when an entitlement + transitions. SDK consumers see them on the SSE stream alongside + real Apple / Google webhooks. + """ + MetaHorizonReconciler +} + +# Environment of the source notification, as reported by the store. +enum WebhookEventEnvironment { + Production + Sandbox + Xcode +} + +# Normalized cross-store subscription state derived from the webhook event. +enum SubscriptionState { + Active + InGracePeriod + InBillingRetry + Expired + Revoked + Refunded + Paused + Unknown +} + +# Why a subscription was canceled, when applicable. +enum WebhookCancellationReason { + UserCanceled + BillingError + PriceIncreaseDeclined + ProductUnavailable + Refunded + Other +} + +# A normalized lifecycle event delivered to clients via Subscription.webhookEvent. +type WebhookEvent { + """ + Stable identifier suitable for idempotency. Derived from the source notification + UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + otherwise hashed from the canonicalized payload. + """ + id: ID! + type: WebhookEventType! + source: WebhookEventSource! + platform: IapPlatform! + """ + kit project that owns the subscription / purchase this event refers to. + """ + projectId: ID! + """ + Time the underlying event occurred at the store. Epoch milliseconds. + """ + occurredAt: Float! + """ + Time kit ingested and normalized this event. Epoch milliseconds. + """ + receivedAt: Float! + environment: WebhookEventEnvironment! + """ + Cross-platform purchase identity used to correlate this event with an existing + purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + Null for `TestNotification` events (Apple ASN v2 / Google RTDN test + payloads carry no transaction); always present for every other event type. + """ + purchaseToken: String + """ + Product the event pertains to. May be null for account-level events. + """ + productId: String + """ + Normalized subscription state at the time of event, when the event refers to + a subscription. Null for one-time purchase events. + """ + subscriptionState: SubscriptionState + """ + When the current subscription period ends. Epoch milliseconds. + """ + expiresAt: Float + """ + When auto-renewal will charge again. Epoch milliseconds. + """ + renewsAt: Float + """ + Reason for cancellation, when applicable. + """ + cancellationReason: WebhookCancellationReason + """ + Localized currency code (ISO 4217) at event time, when available. + """ + currency: String + """ + Price in micros (1/1,000,000 of the currency unit) at event time, when available. + Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + """ + priceAmountMicros: Float + """ + Original signed payload from the store. ASN v2 events expose the JWS string; + RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + consumers can independently verify or extract platform-specific fields. kit + always validates this payload before emitting the event. + """ + rawSignedPayload: String +} + +# Note: webhook event transport is intentionally NOT modeled as a +# GraphQL Subscription / Query field on this spec. The streaming +# endpoint is a kit-server concern delivered over SSE +# (`GET /v1/webhooks/stream/{apiKey}`) — see the per-SDK +# `webhook-client` helpers — and the backfill is a Convex query +# internal to kit that reads `webhookEvents`. Exposing them here +# would force every device-side IAP implementation to declare a +# `webhookEvent` listener / `webhookEventsSince` method even though +# the actual transport is HTTP, not GraphQL. PR #123 (https://github.com/hyodotdev/openiap/pull/123) review caught +# the earlier draft surfacing them as required interface methods on +# the KMP / Dart / Swift IAP classes. diff --git a/packages/kit/Dockerfile b/packages/kit/Dockerfile index 17e712a8..7069242d 100644 --- a/packages/kit/Dockerfile +++ b/packages/kit/Dockerfile @@ -16,6 +16,11 @@ COPY packages/gql/package.json ./packages/gql/ COPY packages/apple/package.json ./packages/apple/ COPY packages/google/package.json ./packages/google/ COPY packages/docs/package.json ./packages/docs/ +# `mcp-server` is a workspace member (added in PR #124 (https://github.com/hyodotdev/openiap/pull/124)). Without +# its package.json bun's workspace resolver disagrees with the +# root lockfile and `--frozen-lockfile` rejects the install with +# "lockfile had changes, but lockfile is frozen". +COPY packages/mcp-server/package.json ./packages/mcp-server/ RUN bun install --frozen-lockfile --filter @hyodotdev/openiap-kit # --- Build the unified app (Vite SPA + compiled Bun server) -------------- @@ -24,6 +29,13 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY package.json bun.lock ./ COPY packages/kit ./packages/kit +# bun 1.3.13 + workspaces installs some deps under each package's +# local node_modules (e.g. `vite`, `@vitejs/plugin-react`) instead of +# fully hoisting. Pull kit's local node_modules from the deps stage +# AFTER the source COPY so it isn't clobbered. Without this the +# build fails with `vite: command not found` (PR #124 (https://github.com/hyodotdev/openiap/pull/124) review +# fallout). +COPY --from=deps /app/packages/kit/node_modules ./packages/kit/node_modules WORKDIR /app/packages/kit ARG VITE_KIT_CONVEX_URL ENV VITE_KIT_CONVEX_URL=${VITE_KIT_CONVEX_URL} diff --git a/packages/kit/convex/_generated/api.d.ts b/packages/kit/convex/_generated/api.d.ts index 917f69fc..a92cd711 100644 --- a/packages/kit/convex/_generated/api.d.ts +++ b/packages/kit/convex/_generated/api.d.ts @@ -17,6 +17,7 @@ import type * as apiKeys_query from "../apiKeys/query.js"; import type * as auth from "../auth.js"; import type * as certificates_apple_root_certificates from "../certificates/apple_root_certificates.js"; import type * as crons from "../crons.js"; +import type * as files_action from "../files/action.js"; import type * as files_internal from "../files/internal.js"; import type * as files_mutation from "../files/mutation.js"; import type * as files_query from "../files/query.js"; @@ -27,10 +28,17 @@ import type * as organizations_internal from "../organizations/internal.js"; import type * as organizations_mutation from "../organizations/mutation.js"; import type * as organizations_query from "../organizations/query.js"; import type * as plans from "../plans.js"; +import type * as products_asc from "../products/asc.js"; +import type * as products_jwt from "../products/jwt.js"; +import type * as products_mutation from "../products/mutation.js"; +import type * as products_play from "../products/play.js"; +import type * as products_query from "../products/query.js"; +import type * as products_sync from "../products/sync.js"; import type * as projects_helpers from "../projects/helpers.js"; import type * as projects_internal from "../projects/internal.js"; import type * as projects_mutation from "../projects/mutation.js"; import type * as projects_query from "../projects/query.js"; +import type * as projects_setupStatus from "../projects/setupStatus.js"; import type * as purchases_action from "../purchases/action.js"; import type * as purchases_android from "../purchases/android.js"; import type * as purchases_cleanup from "../purchases/cleanup.js"; @@ -44,15 +52,30 @@ import type * as purchases_query from "../purchases/query.js"; import type * as purchases_retry from "../purchases/retry.js"; import type * as purchases_shared from "../purchases/shared.js"; import type * as purchases_stats from "../purchases/stats.js"; +import type * as subscriptions_horizon from "../subscriptions/horizon.js"; +import type * as subscriptions_horizonInternal from "../subscriptions/horizonInternal.js"; +import type * as subscriptions_internal from "../subscriptions/internal.js"; +import type * as subscriptions_monthlyMicros from "../subscriptions/monthlyMicros.js"; +import type * as subscriptions_mutation from "../subscriptions/mutation.js"; +import type * as subscriptions_query from "../subscriptions/query.js"; +import type * as subscriptions_stateMachine from "../subscriptions/stateMachine.js"; +import type * as subscriptions_stats from "../subscriptions/stats.js"; import type * as userProfiles_action from "../userProfiles/action.js"; import type * as userProfiles_internal from "../userProfiles/internal.js"; import type * as userProfiles_mutation from "../userProfiles/mutation.js"; import type * as userProfiles_query from "../userProfiles/query.js"; import type * as users_internal from "../users/internal.js"; import type * as users_query from "../users/query.js"; +import type * as utils_concurrency from "../utils/concurrency.js"; import type * as utils_errors from "../utils/errors.js"; import type * as utils_helpers from "../utils/helpers.js"; import type * as utils_validation from "../utils/validation.js"; +import type * as webhooks_apple from "../webhooks/apple.js"; +import type * as webhooks_google from "../webhooks/google.js"; +import type * as webhooks_internal from "../webhooks/internal.js"; +import type * as webhooks_query from "../webhooks/query.js"; +import type * as webhooks_shared from "../webhooks/shared.js"; +import type * as webhooks_validators from "../webhooks/validators.js"; import type { ApiFromModules, @@ -70,6 +93,7 @@ declare const fullApi: ApiFromModules<{ auth: typeof auth; "certificates/apple_root_certificates": typeof certificates_apple_root_certificates; crons: typeof crons; + "files/action": typeof files_action; "files/internal": typeof files_internal; "files/mutation": typeof files_mutation; "files/query": typeof files_query; @@ -80,10 +104,17 @@ declare const fullApi: ApiFromModules<{ "organizations/mutation": typeof organizations_mutation; "organizations/query": typeof organizations_query; plans: typeof plans; + "products/asc": typeof products_asc; + "products/jwt": typeof products_jwt; + "products/mutation": typeof products_mutation; + "products/play": typeof products_play; + "products/query": typeof products_query; + "products/sync": typeof products_sync; "projects/helpers": typeof projects_helpers; "projects/internal": typeof projects_internal; "projects/mutation": typeof projects_mutation; "projects/query": typeof projects_query; + "projects/setupStatus": typeof projects_setupStatus; "purchases/action": typeof purchases_action; "purchases/android": typeof purchases_android; "purchases/cleanup": typeof purchases_cleanup; @@ -97,15 +128,30 @@ declare const fullApi: ApiFromModules<{ "purchases/retry": typeof purchases_retry; "purchases/shared": typeof purchases_shared; "purchases/stats": typeof purchases_stats; + "subscriptions/horizon": typeof subscriptions_horizon; + "subscriptions/horizonInternal": typeof subscriptions_horizonInternal; + "subscriptions/internal": typeof subscriptions_internal; + "subscriptions/monthlyMicros": typeof subscriptions_monthlyMicros; + "subscriptions/mutation": typeof subscriptions_mutation; + "subscriptions/query": typeof subscriptions_query; + "subscriptions/stateMachine": typeof subscriptions_stateMachine; + "subscriptions/stats": typeof subscriptions_stats; "userProfiles/action": typeof userProfiles_action; "userProfiles/internal": typeof userProfiles_internal; "userProfiles/mutation": typeof userProfiles_mutation; "userProfiles/query": typeof userProfiles_query; "users/internal": typeof users_internal; "users/query": typeof users_query; + "utils/concurrency": typeof utils_concurrency; "utils/errors": typeof utils_errors; "utils/helpers": typeof utils_helpers; "utils/validation": typeof utils_validation; + "webhooks/apple": typeof webhooks_apple; + "webhooks/google": typeof webhooks_google; + "webhooks/internal": typeof webhooks_internal; + "webhooks/query": typeof webhooks_query; + "webhooks/shared": typeof webhooks_shared; + "webhooks/validators": typeof webhooks_validators; }>; /** diff --git a/packages/kit/convex/crons.ts b/packages/kit/convex/crons.ts index 45a59ff5..679a61b6 100644 --- a/packages/kit/convex/crons.ts +++ b/packages/kit/convex/crons.ts @@ -1,5 +1,6 @@ import { cronJobs } from "convex/server"; import { internal } from "./_generated/api"; +import { WEBHOOK_RETENTION_MS } from "./webhooks/internal"; const crons = cronJobs(); @@ -35,4 +36,49 @@ crons.interval( internal.userProfiles.internal.drainPendingDeletionOrganizations, ); +// Prune webhook events older than the 30-day retention window so the +// `webhookEventsSince` backfill query stays bounded. Runs hourly with +// a small per-tick batch size — webhook traffic is low-volume per +// project so even a tight batch keeps the table from growing +// unbounded. Matches the retention promise documented in +// `packages/gql/src/webhook.graphql`. +crons.interval( + "prune webhook events past retention", + { hours: 1 }, + internal.webhooks.internal.pruneWebhookEvents, + { olderThanMs: WEBHOOK_RETENTION_MS }, +); + +// Meta Horizon Store has no webhook system — Meta only exposes a +// synchronous `verify_entitlement` Graph API. We poll every 6h to +// reconcile Active / InGracePeriod / Paused subscriptions against +// Meta's authoritative answer, feeding the deltas through the same +// state machine the Apple/Google webhook receivers use. +crons.interval( + "reconcile horizon entitlements", + { hours: 6 }, + internal.subscriptions.horizon.reconcileHorizonEntitlements, + {}, +); + +// Daily drift correction for the incrementally-maintained +// `subscriptionStats` table. The incremental path in +// applySubscriptionEvent / recordHorizonStatus is correct in steady +// state, but a missed invocation (action timeout, manual db.patch, +// schema drift during rollout) can drift the counters. Recomputing +// the most-stale 100 projects per tick keeps the dashboard self- +// healing without operator intervention. +crons.interval( + "recompute subscription stats (drift correction)", + { hours: 24 }, + internal.subscriptions.stats.recomputeAllSubscriptionStats, + // batchSize=50 projects per daily tick. Each project recompute + // runs as its own scheduled mutation (independent 40k document- + // read budget), so the picker mutation only does a tiny index + // scan + 50 schedule calls. With daily cadence + batchSize=50, + // a deployment with up to 1500 projects cycles through every + // project at least monthly. + { batchSize: 50 }, +); + export default crons; diff --git a/packages/kit/convex/files/action.ts b/packages/kit/convex/files/action.ts new file mode 100644 index 00000000..6616daf8 --- /dev/null +++ b/packages/kit/convex/files/action.ts @@ -0,0 +1,76 @@ +"use node"; +import { action } from "../_generated/server"; +import { v, ConvexError } from "convex/values"; +import { internal } from "../_generated/api"; +import { getAuthUserId } from "@convex-dev/auth/server"; +import type { Id } from "../_generated/dataModel"; + +// Public action to download an uploaded credential file (Apple .p8 or +// Google service-account JSON). The dashboard's Settings page calls +// this so an org admin can re-fetch the original file they uploaded — +// useful when rotating keys, copying to a new project, or +// double-checking the file kit holds matches the one in App Store +// Connect / Play Console. +// +// Auth: same admin-or-owner check `files.mutation.remove` enforces. +// Members can't download because the .p8 / service-account JSON are +// effectively credentials. +// +// Returns the file content as a base64 string so the frontend can +// reconstruct a Blob and trigger a browser download. We don't return +// a storage URL because Convex storage URLs are publicly fetchable — +// the auth check belongs in this action, not on a URL the browser +// hands to a third-party. +export const downloadFile = action({ + args: { fileId: v.id("files") }, + returns: v.object({ + fileName: v.string(), + mimeType: v.string(), + base64: v.string(), + }), + handler: async ( + ctx, + args, + ): Promise<{ fileName: string; mimeType: string; base64: string }> => { + const userId: Id<"users"> | null = await getAuthUserId(ctx); + if (!userId) { + throw new ConvexError("Not authenticated"); + } + + // The Convex `files` table stores the MIME type in `fileType` (see + // `files/internal.ts`). The prior typing pulled `mimeType` and so + // every download fell back to `application/octet-stream` — the + // dashboard would then build the Blob with the wrong content type + // and the browser would mis-handle the .p8 / .json download. + const file: { + _id: Id<"files">; + fileName: string; + organizationId: Id<"organizations">; + fileType?: string; + } | null = await ctx.runQuery(internal.files.internal.getFileRecord, { + fileId: args.fileId, + }); + if (!file) { + throw new ConvexError("File not found"); + } + + const membership = await ctx.runQuery( + internal.organizations.internal.getMembership, + { userId, organizationId: file.organizationId }, + ); + if (!membership || membership.role === "member") { + throw new ConvexError("Insufficient permissions"); + } + + const result: { content: string; fileName: string } = await ctx.runAction( + internal.files.internal.readFileAsBase64, + { fileId: args.fileId }, + ); + + return { + fileName: result.fileName, + mimeType: file.fileType ?? "application/octet-stream", + base64: result.content, + }; + }, +}); diff --git a/packages/kit/convex/files/internal.ts b/packages/kit/convex/files/internal.ts index b812595a..f3ca9d82 100644 --- a/packages/kit/convex/files/internal.ts +++ b/packages/kit/convex/files/internal.ts @@ -3,7 +3,7 @@ import { internalMutation, internalAction, } from "../_generated/server"; -import type { Doc } from "../_generated/dataModel"; +import type { Doc, Id } from "../_generated/dataModel"; import { v } from "convex/values"; import { ConvexError } from "convex/values"; import { internal } from "../_generated/api"; @@ -208,6 +208,7 @@ export const findFilesByPurpose = internalQuery({ organizationId: v.id("organizations"), purpose: v.union( v.literal("apple_p8_key"), + v.literal("apple_p8_asc_api_key"), v.literal("android_service_account"), ), }, @@ -298,6 +299,58 @@ export const getAppleP8Key = internalAction({ }, }); +// Internal action to get the App Store Connect API key (.p8). This is +// a different key than `getAppleP8Key` returns — see schema.ts for the +// distinction. Used by `products/asc.ts` push-sync. +export const getAppleAscApiKey = internalAction({ + args: { + organizationId: v.id("organizations"), + projectId: v.optional(v.id("projects")), + }, + handler: async ( + ctx, + args, + ): Promise<{ + keyContent: string; + metadata: unknown; + fileId: Id<"files">; + }> => { + const files = await ctx.runQuery( + internal.files.internal.findFilesByPurpose, + { + organizationId: args.organizationId, + purpose: "apple_p8_asc_api_key", + }, + ); + + let targetFile = files[0]; + if (args.projectId) { + const projectFiles = files.filter( + (f: FilePublicProjection) => f.projectId === args.projectId, + ); + targetFile = projectFiles[0] || files[0]; + } + + if (!targetFile) { + throw new ConvexError( + "No App Store Connect API key (.p8) uploaded for this project — generate one at App Store Connect → Users and Access → Integrations → App Store Connect API and upload it in Settings.", + ); + } + const content = await ctx.runAction( + internal.files.internal.readFileAsText, + { + fileId: targetFile._id, + }, + ); + + return { + keyContent: content.content, + metadata: content.metadata, + fileId: targetFile._id, + }; + }, +}); + // Internal mutation to cleanup old files export const cleanupOldFiles = internalMutation({ args: { @@ -317,8 +370,12 @@ export const cleanupOldFiles = internalMutation({ let deletedCount = 0; for (const file of files) { - // Don't delete internal files or keys - if (file.isInternal || file.purpose === "apple_p8_key") { + // Don't delete internal files or keys (both Apple .p8 kinds). + if ( + file.isInternal || + file.purpose === "apple_p8_key" || + file.purpose === "apple_p8_asc_api_key" + ) { continue; } diff --git a/packages/kit/convex/files/mutation.ts b/packages/kit/convex/files/mutation.ts index 5f49e43c..de7c0d0d 100644 --- a/packages/kit/convex/files/mutation.ts +++ b/packages/kit/convex/files/mutation.ts @@ -13,6 +13,7 @@ export const saveFile = mutation({ fileSize: v.number(), purpose: v.union( v.literal("apple_p8_key"), + v.literal("apple_p8_asc_api_key"), v.literal("android_service_account"), ), description: v.optional(v.string()), diff --git a/packages/kit/convex/files/query.ts b/packages/kit/convex/files/query.ts index 812ac7c5..e22a0862 100644 --- a/packages/kit/convex/files/query.ts +++ b/packages/kit/convex/files/query.ts @@ -9,7 +9,11 @@ export const list = query({ organizationId: v.id("organizations"), projectId: v.optional(v.id("projects")), purpose: v.optional( - v.union(v.literal("apple_p8_key"), v.literal("android_service_account")), + v.union( + v.literal("apple_p8_key"), + v.literal("apple_p8_asc_api_key"), + v.literal("android_service_account"), + ), ), }, handler: async (ctx, args) => { @@ -151,7 +155,11 @@ export const count = query({ args: { organizationId: v.id("organizations"), purpose: v.optional( - v.union(v.literal("apple_p8_key"), v.literal("android_service_account")), + v.union( + v.literal("apple_p8_key"), + v.literal("apple_p8_asc_api_key"), + v.literal("android_service_account"), + ), ), }, handler: async (ctx, args) => { @@ -241,6 +249,50 @@ export const getAppStoreFileByProject = query({ }, }); +// Get the App Store Connect API key (.p8) by project. Genuinely a +// different file from `getAppStoreFileByProject` — see schema.ts. +// Used by `products/asc.ts` for push-sync. +export const getAscApiKeyFileByProject = query({ + args: { + projectId: v.id("projects"), + }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) { + return null; + } + const project = await ctx.db.get(args.projectId); + if (!project) { + return null; + } + const membership = await ctx.db + .query("organizationMembers") + .withIndex("by_org_and_user", (q) => + q.eq("organizationId", project.organizationId).eq("userId", userId), + ) + .first(); + if (!membership) { + return null; + } + const projectFiles = await ctx.db + .query("files") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .collect(); + const ascFile = projectFiles.find( + (f) => f.purpose === "apple_p8_asc_api_key", + ); + if (!ascFile) { + return null; + } + return { + _id: ascFile._id, + fileName: ascFile.fileName, + fileSize: ascFile.fileSize, + uploadedAt: ascFile.createdAt, + }; + }, +}); + // Get Google Play file by project export const getGooglePlayFileByProject = query({ args: { diff --git a/packages/kit/convex/migrations.ts b/packages/kit/convex/migrations.ts index 2dd1f05d..76ef9637 100644 --- a/packages/kit/convex/migrations.ts +++ b/packages/kit/convex/migrations.ts @@ -6,7 +6,6 @@ import { extractProductIdFromRemoteResponse, isValidState, } from "./purchases/shared.js"; -import { HarmonizedPurchaseState } from "./purchases/purchaseState.js"; import { applyPurchaseStatsDelta, deltaForInsert, @@ -69,7 +68,7 @@ export const removeLegacyProfileFields = migrations.define({ export const replaceIsAuthenticWithIsValid = migrations.define({ table: "purchases", migrateOne: async (_ctx, doc) => { - const isValid = isValidState(doc.state as HarmonizedPurchaseState); + const isValid = isValidState(doc.state); return { ...doc, @@ -226,7 +225,7 @@ export const backfillPurchaseProductIds = migrations.define({ * destructive step deliberately kept out of the migration runner. See * `collapseDuplicatePurchasesByOrderId` in * [convex/purchases/cleanup.ts](convex/purchases/cleanup.ts) and the - * deploy sequence in PR #10 for the recommended order of operations. + * deploy sequence in PR #10 (https://github.com/hyodotdev/openiap/pull/10) for the recommended order of operations. */ export const backfillPurchaseOrderIds = migrations.define({ table: "purchases", diff --git a/packages/kit/convex/organizations/internal.ts b/packages/kit/convex/organizations/internal.ts index 62479aee..f796586c 100644 --- a/packages/kit/convex/organizations/internal.ts +++ b/packages/kit/convex/organizations/internal.ts @@ -104,6 +104,37 @@ export const organizationExists = internalQuery({ }, }); +// Lookup helper used by Convex actions that need to gate on +// organization membership without dragging the full org schema into +// the public mutation surface. Returns just the role so the caller +// can do `role === "member"` checks. +export const getMembership = internalQuery({ + args: { + userId: v.id("users"), + organizationId: v.id("organizations"), + }, + returns: v.union( + v.null(), + v.object({ + role: v.union( + v.literal("owner"), + v.literal("admin"), + v.literal("member"), + ), + }), + ), + handler: async (ctx, args) => { + const membership = await ctx.db + .query("organizationMembers") + .withIndex("by_org_and_user", (q) => + q.eq("organizationId", args.organizationId).eq("userId", args.userId), + ) + .first(); + if (!membership) return null; + return { role: membership.role }; + }, +}); + export async function recordVerificationUsageForOrganization( ctx: MutationCtx, organization: Doc<"organizations">, diff --git a/packages/kit/convex/products/asc.test.ts b/packages/kit/convex/products/asc.test.ts new file mode 100644 index 00000000..a22fe69e --- /dev/null +++ b/packages/kit/convex/products/asc.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from "vitest"; + +import { + mapAscOfferDurationToIso, + mapAscOfferKind, + mapBillingPeriodToAsc, + parseIntroOffers, + pickActivePriceRow, + pickPricePointIdMatching, +} from "./asc"; + +describe("pickPricePointIdMatching", () => { + const list = { + data: [ + { + id: "tier-29", + type: "inAppPurchasePricePoints" as const, + attributes: { customerPrice: "0.29" }, + }, + { + id: "tier-99", + type: "inAppPurchasePricePoints" as const, + attributes: { customerPrice: "0.99" }, + }, + { + id: "tier-999", + type: "inAppPurchasePricePoints" as const, + attributes: { customerPrice: "9.99" }, + }, + { + id: "tier-9999", + type: "inAppPurchasePricePoints" as const, + attributes: { customerPrice: "99.99" }, + }, + { + id: "tier-malformed", + type: "inAppPurchasePricePoints" as const, + attributes: { customerPrice: "abc" }, + }, + { + id: "tier-empty", + type: "inAppPurchasePricePoints" as const, + attributes: {}, + }, + ], + }; + + it("returns null when the catalog response is null", () => { + expect(pickPricePointIdMatching(null, 9_990_000)).toBeNull(); + }); + + it("returns null when no tier matches the requested USD amount", () => { + expect(pickPricePointIdMatching(list, 1_500_000)).toBeNull(); + }); + + it("matches an exact tier on the cent boundary", () => { + expect(pickPricePointIdMatching(list, 9_990_000)).toBe("tier-999"); + expect(pickPricePointIdMatching(list, 290_000)).toBe("tier-29"); + expect(pickPricePointIdMatching(list, 99_990_000)).toBe("tier-9999"); + }); + + it("absorbs one-cent floating-point drift in the requested amount", () => { + expect(pickPricePointIdMatching(list, 9_989_999)).toBe("tier-999"); + expect(pickPricePointIdMatching(list, 9_985_000)).toBe("tier-999"); + }); + + it("skips malformed and missing customerPrice rows", () => { + expect(pickPricePointIdMatching(list, 0)).toBeNull(); + }); +}); + +describe("mapBillingPeriodToAsc", () => { + it.each([ + ["P1W", "ONE_WEEK"], + ["P1M", "ONE_MONTH"], + ["P2M", "TWO_MONTHS"], + ["P3M", "THREE_MONTHS"], + ["P6M", "SIX_MONTHS"], + ["P1Y", "ONE_YEAR"], + ] as const)("maps %s → %s", (iso, asc) => { + expect(mapBillingPeriodToAsc(iso)).toBe(asc); + }); + + it("defaults undefined / unknown periods to ONE_MONTH so push doesn't silently drop the picker", () => { + expect(mapBillingPeriodToAsc(undefined)).toBe("ONE_MONTH"); + // Unknown periods throw — silently coercing to ONE_MONTH used + // to provision the wrong subscription duration in ASC, which is + // much harder to unwind than a failed sync. The throw is caught + // inside processOneDraft and recorded as a per-row failure. + const wider = mapBillingPeriodToAsc as ( + period: string | undefined, + ) => string; + expect(() => wider("P9X")).toThrow(/Invalid billing period/); + }); +}); + +describe("mapAscOfferDurationToIso", () => { + it.each([ + ["THREE_DAYS", "P3D"], + ["ONE_WEEK", "P1W"], + ["TWO_WEEKS", "P2W"], + ["ONE_MONTH", "P1M"], + ["TWO_MONTHS", "P2M"], + ["THREE_MONTHS", "P3M"], + ["SIX_MONTHS", "P6M"], + ["ONE_YEAR", "P1Y"], + ])("normalizes ASC enum %s → ISO %s", (asc, iso) => { + expect(mapAscOfferDurationToIso(asc)).toBe(iso); + }); + + it("returns undefined when no input", () => { + expect(mapAscOfferDurationToIso(undefined)).toBeUndefined(); + }); + + it("passes unknown enum values through unchanged so future Apple values still render", () => { + expect(mapAscOfferDurationToIso("FOUR_MOONS")).toBe("FOUR_MOONS"); + }); +}); + +describe("mapAscOfferKind", () => { + it.each([ + ["FREE_TRIAL", "FreeTrial"], + ["PAY_UP_FRONT", "IntroPayUpFront"], + ["PAY_AS_YOU_GO", "IntroPayAsYouGo"], + ] as const)("maps %s → %s", (mode, kind) => { + expect(mapAscOfferKind(mode)).toBe(kind); + }); + + it("falls back to FreeTrial for unknown / undefined modes", () => { + expect(mapAscOfferKind(undefined)).toBe("FreeTrial"); + expect(mapAscOfferKind("UNKNOWN")).toBe("FreeTrial"); + }); +}); + +describe("pickActivePriceRow", () => { + const today = new Date().toISOString().slice(0, 10); + const yesterday = new Date(Date.now() - 86_400_000) + .toISOString() + .slice(0, 10); + const tomorrow = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10); + + it("returns null for empty input", () => { + expect(pickActivePriceRow([])).toBeNull(); + }); + + it("picks the row whose date window covers today", () => { + const rows = [ + { id: "future", attributes: { startDate: tomorrow, endDate: null } }, + { id: "active", attributes: { startDate: yesterday, endDate: null } }, + ]; + expect(pickActivePriceRow(rows)?.id).toBe("active"); + }); + + it("treats null start / end as open bounds", () => { + const rows = [ + { id: "open", attributes: { startDate: null, endDate: null } }, + ]; + expect(pickActivePriceRow(rows)?.id).toBe("open"); + }); + + it("rejects rows whose endDate has already passed", () => { + const rows = [ + { + id: "expired", + attributes: { startDate: yesterday, endDate: yesterday }, + }, + { id: "active", attributes: { startDate: yesterday, endDate: tomorrow } }, + ]; + expect(pickActivePriceRow(rows)?.id).toBe("active"); + }); + + it("falls back to the first row when no window covers today (defensive default)", () => { + const rows = [ + { id: "future-a", attributes: { startDate: tomorrow, endDate: null } }, + { id: "future-b", attributes: { startDate: tomorrow, endDate: null } }, + ]; + expect(pickActivePriceRow(rows)?.id).toBe("future-a"); + }); + + it("accepts a row whose startDate equals today (only strictly-future startDates are rejected)", () => { + const rows = [ + { id: "starts-today", attributes: { startDate: today, endDate: null } }, + ]; + expect(pickActivePriceRow(rows)?.id).toBe("starts-today"); + }); +}); + +describe("parseIntroOffers", () => { + const today = new Date().toISOString().slice(0, 10); + + it("returns [] when no response or empty data", () => { + expect(parseIntroOffers(null)).toEqual([]); + expect(parseIntroOffers({ data: [] })).toEqual([]); + }); + + it("parses a free-trial offer (no pricePoint, just duration)", () => { + const out = parseIntroOffers({ + data: [ + { + id: "offer-free", + type: "subscriptionIntroductoryOffers" as const, + attributes: { + offerMode: "FREE_TRIAL", + duration: "ONE_WEEK", + numberOfPeriods: 1, + startDate: today, + endDate: null, + }, + relationships: {}, + }, + ], + }); + expect(out).toEqual([ + { + id: "offer-free", + kind: "FreeTrial", + duration: "P1W", + numberOfPeriods: 1, + priceAmountMicros: undefined, + currency: undefined, + }, + ]); + }); + + it("parses a pay-up-front intro with included pricePoint", () => { + const out = parseIntroOffers({ + data: [ + { + id: "offer-paid", + type: "subscriptionIntroductoryOffers" as const, + attributes: { + offerMode: "PAY_UP_FRONT", + duration: "THREE_MONTHS", + numberOfPeriods: 1, + }, + relationships: { + subscriptionPricePoint: { + data: { id: "pp-99" }, + }, + }, + }, + ], + included: [ + { + id: "pp-99", + type: "subscriptionPricePoints" as const, + attributes: { customerPrice: "0.99" }, + }, + ], + }); + expect(out).toEqual([ + { + id: "offer-paid", + kind: "IntroPayUpFront", + duration: "P3M", + numberOfPeriods: 1, + priceAmountMicros: 990_000, + currency: "USD", + }, + ]); + }); + + it("filters out offers whose date window doesn't cover today", () => { + const future = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10); + const out = parseIntroOffers({ + data: [ + { + id: "offer-future", + type: "subscriptionIntroductoryOffers" as const, + attributes: { + offerMode: "FREE_TRIAL", + duration: "ONE_WEEK", + startDate: future, + endDate: null, + }, + }, + ], + }); + expect(out).toEqual([]); + }); +}); diff --git a/packages/kit/convex/products/asc.ts b/packages/kit/convex/products/asc.ts new file mode 100644 index 00000000..c25eda37 --- /dev/null +++ b/packages/kit/convex/products/asc.ts @@ -0,0 +1,1798 @@ +"use node"; +import { v } from "convex/values"; + +import { action, type ActionCtx } from "../_generated/server"; +import { internal } from "../_generated/api"; +import { getProjectByApiKey } from "../purchases/shared"; +import { mapWithConcurrency } from "../utils/concurrency"; +import { mintAscJwt } from "./jwt"; +import { coerceBillingPeriod } from "./sync"; + +// Resolve App Store Connect API credentials (issuer ID + key ID + .p8 +// key content) for a project. Centralized so the two action handlers +// (pushSyncProductsAppleIOS and listSubscriptionGroupsAppleIOS) share +// one source of truth — both have to honor the same pair-resolution +// rule (never mix new ASC slot with legacy Server API slot) and the +// same .p8 fallback (dedicated ASC slot first, then legacy single +// slot for projects mid-migration). Throws on missing config or +// missing .p8 with the operator-actionable message we want surfaced. +type AscCredentials = { + issuerId: string; + keyId: string; + keyContent: string; +}; +async function resolveAscCredentials( + ctx: ActionCtx, + project: Awaited>, + options: { detailedErrors?: boolean } = {}, +): Promise { + // Resolve as a *pair* — never mix the new ASC Issuer ID with the + // legacy Server API Key ID (or vice versa). If only one of the + // new fields is populated the operator is mid-migration; in that + // case fall back to the legacy pair entirely so we don't sign a + // request with mismatched identifiers Apple will reject as 401. + const useAsc = project.iosAscIssuerId && project.iosAscKeyId; + const issuerId = useAsc + ? project.iosAscIssuerId + : project.iosAppStoreIssuerId; + const keyId = useAsc ? project.iosAscKeyId : project.iosAppStoreKeyId; + if (!issuerId || !keyId) { + throw new Error( + options.detailedErrors + ? "App Store Connect API Issuer ID / Key ID not configured. " + + "Generate them at App Store Connect → Users and Access → " + + "Integrations → App Store Connect API (NOT under In-App " + + "Purchase — those credentials are scoped to receipt " + + "verification only). Save them in Settings → iOS " + + "Configuration → 'App Store Connect API (push-sync)'." + : "App Store Connect API Issuer ID / Key ID not configured", + ); + } + // Prefer the dedicated ASC .p8 file; fall back to the Server API + // .p8 when the user has only uploaded one. The wrong-kind hint + // from `call()` will tell them to upload a Team Key if Apple + // rejects whichever they have. + let keyContent: string | undefined; + try { + const ascKey = await ctx.runAction( + internal.files.internal.getAppleAscApiKey, + { + organizationId: project.organizationId, + projectId: project._id, + }, + ); + keyContent = ascKey?.keyContent; + } catch (error) { + // Only swallow the documented "no ASC key uploaded" case so we + // can fall through to the legacy slot. Storage / permission / + // transient errors must surface — masking them as "use legacy + // key" hides the real failure and ends up signing requests with + // the wrong key, producing confusing 401s downstream. + // + // The action throws a ConvexError whose message starts with + // "No App Store Connect API key (.p8) uploaded" when the file is + // missing. Anything else rethrows. + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("No App Store Connect API key (.p8) uploaded")) { + throw error; + } + } + if (!keyContent) { + const legacyKey = await ctx.runAction( + internal.files.internal.getAppleP8Key, + { + organizationId: project.organizationId, + projectId: project._id, + }, + ); + keyContent = legacyKey?.keyContent; + } + if (!keyContent) { + throw new Error( + options.detailedErrors + ? "App Store Connect API key (.p8) not uploaded — generate one " + + "at App Store Connect → Users and Access → Integrations → " + + "App Store Connect API → Team Keys and upload it in Settings." + : "App Store Connect API key (.p8) not uploaded", + ); + } + return { issuerId, keyId, keyContent }; +} + +// App Store Connect REST client + push-sync action. +// +// Auth: every request carries a freshly-minted ES256 JWT signed with +// the project's `.p8` key (already stored for App Store Server API +// reuse). Token TTL is 600s with a 60s safety margin before expiry. +// +// Surface area implemented (matches what `@onesub/providers` exposes): +// - listInAppPurchases(appId) → GET /v1/apps/{id}/inAppPurchasesV2 +// - createInAppPurchase(args) → POST /v1/inAppPurchases +// - patchInAppPurchase(id,...) → PATCH /v1/inAppPurchases/{id} +// - listSubscriptionGroups(appId) → GET /v1/apps/{id}/subscriptionGroups +// - listSubscriptions(groupId) → GET /v1/subscriptionGroups/{id}/subscriptions +// - createSubscription(...) → POST /v1/subscriptions +// - patchSubscription(...) → PATCH /v1/subscriptions/{id} +// The `pushSyncProducts` action drives kit→ASC sync for a project. +// +// Failure model: ASC returns an `errors[]` array per the JSON:API +// spec; we throw the response status + the first error's `detail` so +// the dashboard / MCP / SDK surfaces a useful message instead of +// "fetch failed". + +const ASC_BASE = "https://api.appstoreconnect.apple.com"; +const ASC_FETCH_TIMEOUT_MS = 30_000; + +type AscToken = { value: string; expiresAt: number }; + +/** + * Thrown by `AscClient.call` on any non-OK ASC response. The status + * code is preserved so callers can branch on it — e.g. ignore 409 + * Conflict on retried `createSubLocalization` / `createIapLocalization` + * pushes (the upstream resource already exists, the next step still + * applies). Earlier behaviour threw a generic `Error` and forced the + * caller to substring-match the message; this is the typed version. + */ +export class AscApiError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + this.name = "AscApiError"; + } +} + +class AscClient { + private cached: AscToken | null = null; + + constructor( + private readonly issuerId: string, + private readonly keyId: string, + private readonly privateKey: string, + ) {} + + private async token(): Promise { + const now = Math.floor(Date.now() / 1000); + if (this.cached && this.cached.expiresAt - now > 60) { + return this.cached.value; + } + const value = mintAscJwt({ + issuerId: this.issuerId, + keyId: this.keyId, + privateKey: this.privateKey, + ttlSeconds: 600, + }); + this.cached = { value, expiresAt: now + 600 }; + return value; + } + + private async call( + path: string, + init: RequestInit & { body?: string } = {}, + ): Promise { + // Per-request timeout. ASC's REST surface is generally responsive + // (<1s for reads, 1-3s for writes), so 30s is a generous bound + // that catches a hung upstream long before the surrounding + // Convex action's 10-min ceiling. Without this, a single hung + // request can stall the entire push-sync pass — ASC has no + // server-sent keepalive on the REST endpoints. + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ASC_FETCH_TIMEOUT_MS); + let response: Response; + try { + response = await fetch(`${ASC_BASE}${path}`, { + ...init, + signal: controller.signal, + headers: { + authorization: `Bearer ${await this.token()}`, + ...(init.body ? { "content-type": "application/json" } : {}), + accept: "application/json", + ...(init.headers as Record | undefined), + }, + }); + } finally { + clearTimeout(timer); + } + const text = await response.text(); + let parsed: unknown = text; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + // leave as text + } + } + if (!response.ok) { + const errorMessage = extractAscError(parsed); + // Apple's 401 is the same generic "Provide a properly configured + // and signed bearer token" for several distinct failure modes, + // and the most common one — uploading the In-App Purchase Key + // instead of the App Store Connect API (Team / Individual) Key + // — looks indistinguishable from "expired token" or "wrong + // signature" without context. Surface a targeted hint so the + // operator stops debugging the JWT and starts checking the + // *kind* of key they uploaded. + const message = + response.status === 401 + ? `ASC ${path} returned 401: ${errorMessage}\n` + + "HINT: ASC REST endpoints (/v1/apps/.../inAppPurchasesV2, " + + "subscriptionGroups, …) require the App Store Connect API " + + "Team Key (or Individual Key) — found under Users and " + + "Access → Integrations → App Store Connect API. The " + + "In-App Purchase Key (Users and Access → Integrations → " + + "In-App Purchase) is a different key and only works for " + + "the App Store Server API (receipt verification). Both " + + "are .p8 files but Apple scopes them separately. Re-upload " + + "the .p8 generated under 'App Store Connect API' and use " + + "ITS Issuer ID + Key ID in the dashboard." + : `ASC ${path} returned ${response.status}: ${errorMessage}`; + // Use a typed AscApiError so callers can branch on + // `.status === 409` to ignore "already exists" replays during + // retried localization / price-schedule pushes (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + throw new AscApiError(response.status, message); + } + return parsed as T; + } + + // ASC list endpoints cap at 200 items per page. For accounts with + // larger catalogs we have to follow `links.next` until absent or + // pages > 200 (= 40k items, more than ASC actually allows per app + // — the bound just prevents a runaway loop on unexpected response + // shapes). Without pagination, accounts above the page limit silently + // lose products from kit's catalog. + async listInAppPurchases(appId: string): Promise { + return this.collectAllPages( + `/v1/apps/${encodeURIComponent(appId)}/inAppPurchasesV2?limit=200`, + ); + } + + async listSubscriptionGroups( + appId: string, + ): Promise { + return this.collectAllPages( + `/v1/apps/${encodeURIComponent(appId)}/subscriptionGroups?limit=200`, + ); + } + + async listSubscriptionsInGroup(groupId: string): Promise { + return this.collectAllPages( + `/v1/subscriptionGroups/${encodeURIComponent(groupId)}/subscriptions?limit=200`, + ); + } + + // Generic JSON:API paginator. ASC returns `{ data: [...], + // links: { self, next? } }` — we follow `next` (the cursor URL is + // absolute, so we hand it straight back to fetch via `call`'s base + // join logic). Capped at 200 pages as a runaway guard. + private async collectAllPages( + initialPath: string, + ): Promise<{ data: T[] }> { + const merged: T[] = []; + let path: string | null = initialPath; + let pages = 0; + while (path && pages < 200) { + const page: { data: T[]; links?: { next?: string } } = + await this.call(path); + merged.push(...page.data); + const nextUrl = page.links?.next ?? null; + path = nextUrl ? this.relativizePath(nextUrl) : null; + pages += 1; + } + return { data: merged }; + } + + // ASC `links.next` is fully qualified (`https://api.appstoreconnect…`). + // `call()` already prepends ASC_BASE, so strip the host before + // passing it back in. + private relativizePath(absoluteOrRelative: string): string { + if (absoluteOrRelative.startsWith(ASC_BASE)) { + return absoluteOrRelative.slice(ASC_BASE.length); + } + return absoluteOrRelative; + } + + // Introductory offer attached to a subscription. Apple allows at + // most ONE introductoryOffer per subscription per territory at a + // time — the prior `pay-up-front $0.99 for 3 months` is replaced + // when you publish a new one. We pull the USA territory's active + // offer (if any) so the dashboard can render badges like + // "7-day free trial" / "$0.99 intro for 3 months". Returns Error + // so the caller can append a failure row instead of silently + // dropping offer metadata. + async subIntroductoryOffer( + subId: string, + ): Promise { + try { + return await this.call( + `/v1/subscriptions/${encodeURIComponent(subId)}/introductoryOffers?filter[territory]=USA&include=subscriptionPricePoint&limit=10`, + ); + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } + } + + // Per-product *configured* USA price. The naive + // `/{type}/{id}/pricePoints?filter[territory]=USA&limit=1` endpoint + // returns the entire USA *price matrix* (every tier the catalog + // offers — $0.29, $0.49, $0.99, …), not the price the operator + // assigned to the product, so `limit=1` always pinned the lowest + // tier and every IAP / sub showed up as $0.29. The actual assigned + // price lives on a different relationship — `iapPriceSchedule` for + // one-time IAPs, `prices` for subscriptions — with the matching + // pricePoint side-loaded via `include`. + // Returns either the price response or an Error so the caller can + // surface the actual ASC reason (404, 403, malformed schedule, …) + // through the sync result's `failures` array — silently swallowing + // these is what made one-time IAPs show "—" with no diagnostic. + async iapCurrentPrice( + iapId: string, + ): Promise { + // v2 IAPs expose the price-schedule relationship under `/v2/` + // (the per-resource endpoints moved with the V2 catalog), even + // though the catalog list is `/v1/apps/{id}/inAppPurchasesV2` + // and the JSON:API resource type is still `"inAppPurchases"`. The + // older `/v1/inAppPurchases/{id}/iapPriceSchedule` 404s with + // "relationship 'iapPriceSchedule' does not exist" because that + // path resolves to the legacy V1 IAP resource which has no such + // relationship. The downstream `manualPrices` collection lookup + // stays on `/v1/inAppPurchasePriceSchedules/...`. + try { + const schedule = await this.call( + `/v2/inAppPurchases/${encodeURIComponent(iapId)}/iapPriceSchedule`, + ); + if (!schedule?.data?.id) { + return new Error( + "iapPriceSchedule returned no data — IAP has no price schedule yet", + ); + } + const manual = await this.call( + `/v1/inAppPurchasePriceSchedules/${encodeURIComponent(schedule.data.id)}/manualPrices?filter[territory]=USA&include=inAppPurchasePricePoint`, + ); + // When the IAP uses Apple's equalized auto-pricing instead of + // per-territory manual prices, `manualPrices` comes back empty + // and the assigned USA price actually lives on the parallel + // `automaticPrices` collection (same envelope shape). + if (manual.data.length === 0) { + return await this.call( + `/v1/inAppPurchasePriceSchedules/${encodeURIComponent(schedule.data.id)}/automaticPrices?filter[territory]=USA&include=inAppPurchasePricePoint`, + ); + } + return manual; + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } + } + async subCurrentPrice( + subId: string, + ): Promise { + try { + return await this.call( + `/v1/subscriptions/${encodeURIComponent(subId)}/prices?filter[territory]=USA&include=subscriptionPricePoint`, + ); + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } + } + + // Find a USA price-point id whose `customerPrice` matches the + // requested USD amount. Apple manages prices via opaque tier ids + // (eyJ...) — to set a price you can't just send "9.99", you must + // pass the price-point resource id corresponding to that tier in + // USA. We fetch the catalog once per (resource, amount) lookup. + // + // Errors propagate verbatim so the call site can distinguish + // "no tier matches USD 9.99" (returns null after a successful + // list) from "ASC returned 401 / 429 / timeout" (throws). The + // prior `.catch(() => null)` collapsed both into the same null + // result and surfaced a real upstream failure as a bogus catalog + // validation error. + async findIapUsaPricePointId( + iapId: string, + targetMicros: number, + ): Promise { + const list = await this.call( + `/v1/inAppPurchases/${encodeURIComponent(iapId)}/pricePoints?filter[territory]=USA&limit=200`, + ); + return pickPricePointIdMatching(list, targetMicros); + } + async findSubUsaPricePointId( + subId: string, + targetMicros: number, + ): Promise { + const list = await this.call( + `/v1/subscriptions/${encodeURIComponent(subId)}/pricePoints?filter[territory]=USA&limit=200`, + ); + return pickPricePointIdMatching(list, targetMicros); + } + + // Atomically create the IAP price schedule with the chosen USA + // price tier. Apple's pattern: POST `inAppPurchasePriceSchedules` + // with the IAP relationship + the manualPrices relationship inline, + // and pass the price rows in `included`. Returns the schedule id. + setIapPriceSchedule(args: { + iapId: string; + pricePointId: string; + startDate?: string; // YYYY-MM-DD; omit for "effective immediately" + }) { + const priceLid = "newPrice"; + const today = args.startDate ?? new Date().toISOString().slice(0, 10); + return this.call<{ data: { id: string } }>( + `/v1/inAppPurchasePriceSchedules`, + { + method: "POST", + body: JSON.stringify({ + data: { + type: "inAppPurchasePriceSchedules", + relationships: { + inAppPurchase: { + data: { type: "inAppPurchases", id: args.iapId }, + }, + manualPrices: { + data: [{ type: "inAppPurchasePrices", id: priceLid }], + }, + }, + }, + included: [ + { + type: "inAppPurchasePrices", + id: priceLid, + attributes: { startDate: today }, + relationships: { + inAppPurchasePricePoint: { + data: { + type: "inAppPurchasePricePoints", + id: args.pricePointId, + }, + }, + inAppPurchaseV2: { + data: { type: "inAppPurchases", id: args.iapId }, + }, + }, + }, + ], + }), + }, + ); + } + setSubPriceSchedule(args: { + subId: string; + pricePointId: string; + startDate?: string; + }) { + const priceLid = "newSubPrice"; + const today = args.startDate ?? new Date().toISOString().slice(0, 10); + return this.call<{ data: { id: string } }>(`/v1/subscriptionPrices`, { + method: "POST", + body: JSON.stringify({ + data: { + type: "subscriptionPrices", + id: priceLid, + attributes: { startDate: today }, + relationships: { + subscription: { + data: { type: "subscriptions", id: args.subId }, + }, + subscriptionPricePoint: { + data: { + type: "subscriptionPricePoints", + id: args.pricePointId, + }, + }, + }, + }, + }), + }); + } + + // Attach an English (US) localization so reviewers and the + // dashboard see something other than the bare productId. Apple + // requires at least one locale before the IAP can be submitted; we + // always create en-US so first-submission isn't blocked. + createIapLocalization(args: { + iapId: string; + name: string; + description: string; + locale?: string; + }) { + return this.call<{ data: { id: string } }>( + `/v1/inAppPurchaseLocalizations`, + { + method: "POST", + body: JSON.stringify({ + data: { + type: "inAppPurchaseLocalizations", + attributes: { + name: args.name, + description: args.description, + locale: args.locale ?? "en-US", + }, + relationships: { + inAppPurchaseV2: { + data: { type: "inAppPurchases", id: args.iapId }, + }, + }, + }, + }), + }, + ); + } + createSubLocalization(args: { + subId: string; + name: string; + description: string; + locale?: string; + }) { + return this.call<{ data: { id: string } }>( + `/v1/subscriptionLocalizations`, + { + method: "POST", + body: JSON.stringify({ + data: { + type: "subscriptionLocalizations", + attributes: { + name: args.name, + description: args.description, + locale: args.locale ?? "en-US", + }, + relationships: { + subscription: { + data: { type: "subscriptions", id: args.subId }, + }, + }, + }, + }), + }, + ); + } + + // Look up an existing subscription group by referenceName, or + // create one. Used by the Add Product flow when the operator types + // a group name on a Subscription draft — kit then resolves it to + // an ASC group id at push time so they don't need to copy/paste + // opaque ids from ASC's web console. + async findOrCreateSubscriptionGroup(args: { + appId: string; + referenceName: string; + }): Promise { + const groups = await this.listSubscriptionGroups(args.appId); + const existing = groups.data.find( + (g) => g.attributes.referenceName === args.referenceName, + ); + if (existing) return existing.id; + const created = await this.call<{ data: { id: string } }>( + `/v1/subscriptionGroups`, + { + method: "POST", + body: JSON.stringify({ + data: { + type: "subscriptionGroups", + attributes: { referenceName: args.referenceName }, + relationships: { + app: { data: { type: "apps", id: args.appId } }, + }, + }, + }), + }, + ); + return created.data.id; + } + + createInAppPurchase(args: { + appId: string; + productId: string; + name: string; + type: "CONSUMABLE" | "NON_CONSUMABLE" | "NON_RENEWING_SUBSCRIPTION"; + reviewNote?: string; + }) { + return this.call(`/v1/inAppPurchases`, { + method: "POST", + body: JSON.stringify({ + data: { + type: "inAppPurchases", + attributes: { + name: args.name, + productId: args.productId, + inAppPurchaseType: args.type, + reviewNote: args.reviewNote, + }, + relationships: { + app: { data: { type: "apps", id: args.appId } }, + }, + }, + }), + }); + } + + patchInAppPurchase( + id: string, + attributes: { name?: string; reviewNote?: string }, + ) { + return this.call( + `/v1/inAppPurchases/${encodeURIComponent(id)}`, + { + method: "PATCH", + body: JSON.stringify({ + data: { type: "inAppPurchases", id, attributes }, + }), + }, + ); + } + + createSubscription(args: { + groupId: string; + productId: string; + name: string; + subscriptionPeriod: + | "ONE_WEEK" + | "ONE_MONTH" + | "TWO_MONTHS" + | "THREE_MONTHS" + | "SIX_MONTHS" + | "ONE_YEAR"; + reviewNote?: string; + }) { + return this.call(`/v1/subscriptions`, { + method: "POST", + body: JSON.stringify({ + data: { + type: "subscriptions", + attributes: { + name: args.name, + productId: args.productId, + subscriptionPeriod: args.subscriptionPeriod, + reviewNote: args.reviewNote, + }, + relationships: { + group: { + data: { type: "subscriptionGroups", id: args.groupId }, + }, + }, + }, + }), + }); + } + + patchSubscription( + id: string, + attributes: { name?: string; reviewNote?: string }, + ) { + return this.call( + `/v1/subscriptions/${encodeURIComponent(id)}`, + { + method: "PATCH", + body: JSON.stringify({ + data: { type: "subscriptions", id, attributes }, + }), + }, + ); + } +} + +type AscIapResource = { + data: { + id: string; + type: "inAppPurchases"; + attributes: { + productId?: string; + name?: string; + inAppPurchaseType?: string; + state?: string; + reviewNote?: string; + }; + }; +}; + +type AscIapListResponse = { + data: AscIapResource["data"][]; +}; + +type AscSubResource = { + data: { + id: string; + type: "subscriptions"; + attributes: { + productId?: string; + name?: string; + subscriptionPeriod?: string; + state?: string; + reviewNote?: string; + }; + }; +}; + +type AscSubListResponse = { + data: AscSubResource["data"][]; +}; + +type AscSubGroupListResponse = { + data: Array<{ + id: string; + type: "subscriptionGroups"; + attributes: { referenceName?: string }; + }>; +}; + +// Reference catalog response: every USA price point Apple publishes +// for a given IAP / sub. Used at push-time to translate a USD amount +// into the corresponding opaque price-point id (`eyJ...`) Apple's +// price-schedule POST requires. Different shape from the +// per-product *configured* price (`AscManualPricesResponse`) — this +// is the immutable tier ladder, that one is the operator's pick. +type AscPricePointListResponse = { + data: Array<{ + id: string; + type: "inAppPurchasePricePoints" | "subscriptionPricePoints"; + attributes?: { customerPrice?: string }; + }>; +}; + +// Find the price-point id whose `customerPrice` matches the desired +// USD amount (within 1 cent for floating-point safety). Returns null +// if Apple's catalog has no matching tier — caller should surface a +// failure so the operator picks a tier ASC actually publishes. +export function pickPricePointIdMatching( + list: AscPricePointListResponse | null, + targetMicros: number, +): string | null { + if (!list) return null; + const targetCents = Math.round(targetMicros / 10_000); + for (const point of list.data) { + const raw = point.attributes?.customerPrice; + if (!raw) continue; + const n = Number(raw); + if (!Number.isFinite(n)) continue; + const pointCents = Math.round(n * 100); + if (Math.abs(pointCents - targetCents) <= 1) return point.id; + } + return null; +} + +// Schedule lookup for one-time IAPs. We only need the resource id so +// we can fetch its `manualPrices` collection; relationships and +// attributes are intentionally untyped. +type AscIapPriceScheduleResponse = { + data?: { id: string; type: "inAppPurchasePriceSchedules" } | null; +}; + +// `manualPrices` (one-time IAP) and `subscriptionPrices` (auto-renew +// sub) share the same JSON:API envelope: a primary `data` row that +// references a pricePoint, and the actual `customerPrice` lives on +// the side-loaded resource in `included`. We narrow only the fields +// we read. +type AscManualPricesResponse = { + data: Array<{ + id: string; + type: "inAppPurchasePrices"; + attributes?: { startDate?: string | null; endDate?: string | null }; + relationships?: { + inAppPurchasePricePoint?: { data?: { id: string } | null }; + }; + }>; + included?: Array<{ + id: string; + type: "inAppPurchasePricePoints"; + attributes?: { customerPrice?: string }; + }>; +}; + +type AscSubscriptionPricesResponse = { + data: Array<{ + id: string; + type: "subscriptionPrices"; + attributes?: { startDate?: string | null; endDate?: string | null }; + relationships?: { + subscriptionPricePoint?: { data?: { id: string } | null }; + }; + }>; + included?: Array<{ + id: string; + type: "subscriptionPricePoints"; + attributes?: { customerPrice?: string }; + }>; +}; + +// Introductory offers list. Apple's `offerMode` enum: +// - "FREE_TRIAL" — duration of free access; no pricePoint +// - "PAY_UP_FRONT" — single discounted price for N periods +// - "PAY_AS_YOU_GO" — discounted price each period for N periods +// `numberOfPeriods` semantics differ by mode (free trial: 1; pay-up: +// 1; pay-as-you-go: N) so we surface it as-is and let the dashboard +// label it. `subscriptionPricePoint` is included for the discounted +// price; absent for free trials. +type AscIntroOfferListResponse = { + data: Array<{ + id: string; + type: "subscriptionIntroductoryOffers"; + attributes?: { + offerMode?: "FREE_TRIAL" | "PAY_UP_FRONT" | "PAY_AS_YOU_GO"; + duration?: string; // ISO-8601-ish: "ONE_WEEK", "THREE_DAYS", etc. + numberOfPeriods?: number; + startDate?: string | null; + endDate?: string | null; + }; + relationships?: { + subscriptionPricePoint?: { data?: { id: string } | null }; + }; + }>; + included?: Array<{ + id: string; + type: "subscriptionPricePoints"; + attributes?: { customerPrice?: string }; + }>; +}; + +// Pick the price record that's currently in effect (today between +// startDate and endDate, treating either bound's absence as "open"). +// ASC normally returns just one row when no scheduled change is +// pending, but a future-dated price-change creates a second record so +// we can't just take `data[0]`. +export function pickActivePriceRow< + T extends { + attributes?: { startDate?: string | null; endDate?: string | null }; + }, +>(rows: T[]): T | null { + if (!rows.length) return null; + const today = new Date().toISOString().slice(0, 10); + const active = rows.find((row) => { + const start = row.attributes?.startDate ?? null; + const end = row.attributes?.endDate ?? null; + if (start && start > today) return false; + if (end && end < today) return false; + return true; + }); + return active ?? rows[0]; +} + +// Generic shape both manual-price (one-time IAP) and subscription- +// price responses collapse into for parsing — primary row points to a +// pricePoint resource via a named relationship, included carries the +// `customerPrice`. Names of those keys vary between the two surfaces; +// we pass them in instead of branching inside. +type AscPriceCollectionResponse = { + data: Array<{ + id: string; + type: string; + attributes?: { startDate?: string | null; endDate?: string | null }; + relationships?: Record< + string, + { data?: { id: string } | null } | undefined + >; + }>; + included?: Array<{ + id: string; + type: string; + attributes?: { customerPrice?: string }; + }>; +}; + +// Resolve the active price record's pricePoint id and look up its +// `customerPrice` from the `included` array. Returns empty fields +// when nothing matches (no schedule, no USA price, ASC error) so the +// caller can pass the result straight into upsertFromStore. +function parseAssignedPrice( + resp: AscPriceCollectionResponse | null, + relationshipKey: "inAppPurchasePricePoint" | "subscriptionPricePoint", +): { priceAmountMicros?: number; currency?: string } { + if (!resp) return {}; + const row = pickActivePriceRow(resp.data); + if (!row) return {}; + const pointId = row.relationships?.[relationshipKey]?.data?.id; + if (!pointId) return {}; + const point = resp.included?.find((entry) => entry.id === pointId); + const raw = point?.attributes?.customerPrice; + if (!raw) return {}; + const n = Number(raw); + if (!Number.isFinite(n)) return {}; + return { + priceAmountMicros: Math.round(n * 1_000_000), + currency: "USD", + }; +} + +function extractAscError(parsed: unknown): string { + if ( + parsed && + typeof parsed === "object" && + "errors" in parsed && + Array.isArray((parsed as { errors: unknown[] }).errors) + ) { + const errors = ( + parsed as { errors: Array<{ detail?: string; title?: string }> } + ).errors; + return ( + errors.map((e) => e.detail ?? e.title ?? "").join("; ") || "(no detail)" + ); + } + return typeof parsed === "string" ? parsed : "(non-JSON error)"; +} + +// --------------------------------------------------------------------------- +// Push-sync action: pulls the project's catalog from ASC, upserts kit's +// `products` rows from it, and pushes any kit-side products with state +// = "Draft" / "Ready" upstream. +// --------------------------------------------------------------------------- + +// NOTE on action duration: this handler runs sequentially across the +// project's catalog (with mapWithConcurrency=6 fan-out per pull +// step). Convex actions have a 10-minute hard ceiling. For typical +// commercial apps with <100 SKUs the round trips finish in ~10-30s +// even with throttled ASC. Catalogs north of ~500 SKUs may need +// batching — splitting drafts into chunks and chaining +// internal.scheduler runs from the kit dashboard. Tracked as a +// follow-up; not addressed here because v0 SaaS targets the +// long-tail-of-commercial-apps profile, not multi-thousand-SKU +// enterprise catalogs. +export const pushSyncProductsAppleIOS = action({ + args: { + apiKey: v.string(), + direction: v.optional( + v.union(v.literal("pull"), v.literal("push"), v.literal("both")), + ), + // Dry-run mode: read-only against ASC. Skips every POST/PATCH + // (create subscription / IAP, localization, price schedule, group + // create) and instead returns the sequence of write attempts + // kit would have made. Lets the operator preview a Sync without + // polluting their App Store Connect catalog with test rows that + // Apple won't let them delete cleanly. + dryRun: v.optional(v.boolean()), + }, + returns: v.object({ + pulled: v.number(), + pushed: v.number(), + failures: v.array(v.object({ productId: v.string(), reason: v.string() })), + plannedWrites: v.optional( + v.array( + v.object({ + productId: v.string(), + step: v.string(), + detail: v.optional(v.string()), + }), + ), + ), + }), + handler: async ( + ctx, + args, + ): Promise<{ + pulled: number; + pushed: number; + failures: Array<{ productId: string; reason: string }>; + plannedWrites?: Array<{ + productId: string; + step: string; + detail?: string; + }>; + }> => { + const project = await getProjectByApiKey(ctx, args.apiKey); + if (!project.iosBundleId) { + throw new Error("Project iosBundleId is not configured"); + } + if (!project.iosAppAppleId) { + throw new Error("Project iosAppAppleId is required for ASC push-sync"); + } + // ASC push-sync uses the App Store Connect API key (Team Key / + // Individual Key), which is genuinely different from the App Store + // Server API key used for receipt verification — Apple scopes them + // separately at the gateway. We prefer the dedicated ASC slot when + // the operator has populated it, but fall back to the existing + // Server API slot so projects that upload a Team Key into the old + // (single-slot) workflow keep working without a re-config dance. + // The 401 from Apple's gateway is what catches a wrong-kind key + // either way — the helpful message in `call()` points the operator + // at the right Apple page. The full pair-resolve + .p8-fallback + // logic lives in `resolveAscCredentials` so the matching + // listSubscriptionGroupsAppleIOS handler stays in lockstep. + const { issuerId, keyId, keyContent } = await resolveAscCredentials( + ctx, + project, + { detailedErrors: true }, + ); + const client = new AscClient(issuerId, keyId, keyContent); + + const direction = args.direction ?? "both"; + const failures: Array<{ productId: string; reason: string }> = []; + let pulled = 0; + let pushed = 0; + const dryRun = args.dryRun ?? false; + const plannedWrites: Array<{ + productId: string; + step: string; + detail?: string; + }> = []; + + const appIdStr = String(project.iosAppAppleId); + + // ── PULL: ASC → kit catalog ──────────────────────────────────── + if (direction === "pull" || direction === "both") { + const iaps = await client.listInAppPurchases(appIdStr).catch((error) => { + failures.push({ + productId: "(asc list iaps)", + reason: error instanceof Error ? error.message : String(error), + }); + return null; + }); + if (iaps) { + // Apple throttles ASC pretty aggressively (~50 req/min); + // concurrency=6 keeps the pull fast for catalogs with dozens + // of IAPs while staying well clear of 429 territory. Switching + // from a sequential await loop dropped a 30-IAP pull from + // ~30s to ~5s in local testing. + const iapResults = await mapWithConcurrency( + iaps.data, + 6, + async (item) => { + const productId = item.attributes.productId; + if (!productId) return null; + const type = mapAscIapType(item.attributes.inAppPurchaseType); + const pricePoint = await client.iapCurrentPrice(item.id); + return { item, productId, type, pricePoint }; + }, + ); + for (const result of iapResults) { + if (!result) continue; + const { item, productId, type, pricePoint } = result; + if (pricePoint instanceof Error) { + failures.push({ + productId: `${productId} (price lookup)`, + reason: pricePoint.message, + }); + } + const { priceAmountMicros, currency } = parseAssignedPrice( + pricePoint instanceof Error ? null : pricePoint, + "inAppPurchasePricePoint", + ); + // upsertFromStore runs serially — Convex coalesces writes + // anyway and parallel mutations on the same row would race + // on the (projectId, platform, productId) lookup. + await ctx.runMutation(internal.products.sync.upsertFromStore, { + projectId: project._id, + productId, + platform: "IOS", + type, + title: item.attributes.name ?? productId, + priceAmountMicros, + currency, + storeRef: item.id, + state: mapAscState(item.attributes.state), + }); + pulled += 1; + } + } + + const groups = await client + .listSubscriptionGroups(appIdStr) + .catch((error) => { + failures.push({ + productId: "(asc list groups)", + reason: error instanceof Error ? error.message : String(error), + }); + return null; + }); + if (groups) { + for (const group of groups.data) { + const subs = await client + .listSubscriptionsInGroup(group.id) + .catch((error) => { + failures.push({ + productId: `(asc list subs in group ${group.id})`, + reason: error instanceof Error ? error.message : String(error), + }); + return null; + }); + if (!subs) continue; + // Same parallelization as the IAP loop above. Within each + // sub, price lookup and intro-offer lookup are independent + // — fire them as a Promise.all to halve the per-item RTT + // before walking on to the upsert. + const subResults = await mapWithConcurrency( + subs.data, + 6, + async (sub) => { + const productId = sub.attributes.productId; + if (!productId) return null; + const [pricePoint, introOffers] = await Promise.all([ + client.subCurrentPrice(sub.id), + client.subIntroductoryOffer(sub.id), + ]); + return { sub, productId, pricePoint, introOffers }; + }, + ); + for (const result of subResults) { + if (!result) continue; + const { sub, productId, pricePoint, introOffers } = result; + if (pricePoint instanceof Error) { + failures.push({ + productId: `${productId} (price lookup)`, + reason: pricePoint.message, + }); + } + const { priceAmountMicros, currency } = parseAssignedPrice( + pricePoint instanceof Error ? null : pricePoint, + "subscriptionPricePoint", + ); + if (introOffers instanceof Error) { + failures.push({ + productId: `${productId} (offers lookup)`, + reason: introOffers.message, + }); + } + const offers = parseIntroOffers( + introOffers instanceof Error ? null : introOffers, + ); + await ctx.runMutation(internal.products.sync.upsertFromStore, { + projectId: project._id, + productId, + platform: "IOS", + type: "Subscription", + title: sub.attributes.name ?? productId, + priceAmountMicros, + currency, + storeRef: sub.id, + state: mapAscState(sub.attributes.state), + billingPeriod: coerceBillingPeriod( + mapAscOfferDurationToIso( + sub.attributes.subscriptionPeriod ?? undefined, + ), + ), + subscriptionGroupId: group.id, + subscriptionGroupName: group.attributes.referenceName, + offers: offers.length ? offers : undefined, + }); + pulled += 1; + } + } + } + } + + // ── PUSH: kit → ASC for Draft rows ───────────────────────────── + // Each draft becomes a multi-step flow: create → localize → set + // price. The first step alone leaves the IAP/sub in an unsubmittable + // state because Apple requires both an en-US localization and a + // USA price schedule before the row can move past Draft. We do + // the whole chain here so a single Sync click takes the catalog + // from "kit-only" to "Ready to Submit" in App Store Connect. + // Submission itself (screenshot upload + inAppPurchaseSubmissions + // POST) is a follow-up because it needs a screenshot file and a + // dashboard upload slot we haven't built yet — see TODO below. + if (direction === "push" || direction === "both") { + const drafts = await ctx.runQuery( + internal.products.sync.listDraftIosProducts, + { projectId: project._id }, + ); + // Cache subscriptionGroup find-or-create results across the + // entire push pass so a project with multiple drafts in the + // same group (Premium Monthly + Premium Yearly + Premium + // Weekly all referencing groupName="Premium") only triggers + // one ASC listSubscriptionGroups round-trip — and never two + // concurrent create calls racing for the same name. + // + // Stores the in-flight promise (not the resolved id) so two + // drafts that hit the same name concurrently share one ASC + // round-trip. Without this the parallel push fan-out below + // could race two find-or-create calls for the same group, + // ending up with one of them returning a 409. + const groupIdCache = new Map>(); + // Dry-run uses a single up-front listSubscriptionGroups fetch + // (read-only) so the per-draft preview rendering doesn't + // re-list the groups for each Subscription row in drafts. + // Lazy: only fetched on the first Subscription draft we hit + // in dry-run, so projects without Sub drafts don't pay the + // call at all. + let dryRunGroupsCache: Awaited< + ReturnType + > | null = null; + const ensureDryRunGroups = async () => { + if (!dryRunGroupsCache) { + dryRunGroupsCache = await client.listSubscriptionGroups(appIdStr); + } + return dryRunGroupsCache; + }; + // Bounded-parallel push. ASC throttles aggressively on the + // mutation endpoints (createSubscription / createInAppPurchase / + // setPriceSchedule) so the previous sequential `for (const row + // of drafts)` loop was the safe-but-slow path; a project with + // 20 draft products waited 20× the per-draft round-trip. Run + // PUSH_CONCURRENCY drafts in parallel and trade some risk of a + // 429 (where ASC returns Retry-After we'd surface to the + // failures array) for an N× speedup. + // + // Each draft's create → localize → setPrice steps stay strictly + // sequential within `processOneDraft` — ASC rejects ordering + // races on a single resource (a localize call landing before + // the create propagates returns 409). Cross-draft parallelism + // is safe because each upstream resource is independent. The + // groupIdCache holds in-flight promises so concurrent drafts in + // the same subscription group still issue exactly one + // findOrCreate call. + // + // Concurrency=4 keeps us well under ASC's per-app rate limit + // (anecdotally ~10 writes/sec before 429s start) while + // delivering ~4× wall-clock improvement on typical catalogs. + // mapWithConcurrency preserves input order for the result + // array (we don't actually use it; failures + pushed are + // accumulated by mutation). + const PUSH_CONCURRENCY = 4; + const processOneDraft = async ( + row: (typeof drafts)[number], + ): Promise => { + // Track failures pushed *for this row* via a row-local flag. + // The previous `failuresAtStart = failures.length` snapshot + // worked when this loop was sequential, but with + // mapWithConcurrency (PUSH_CONCURRENCY=4) the shared + // `failures` array can grow because of OTHER concurrent + // drafts between the snapshot and the success-gate check — + // which would block this draft from calling markPushed even + // though every step for THIS row succeeded. + // + // Use a row-local boolean + a recordFailure helper so each + // draft's success gate is independent of cross-draft noise. + // A partial setup (create succeeded, localization failed) + // still leaves the row in Draft with a populated storeRef + // so the next sync resumes step 2 instead of re-creating + // the upstream resource. + let rowHadFailure = false; + const recordFailure = (failure: { + productId: string; + reason: string; + }) => { + rowHadFailure = true; + failures.push(failure); + }; + try { + if (row.type === "Subscription") { + // Resolve the ASC subscriptionGroup from the operator-typed + // `subscriptionGroupName`. Find-or-create so the operator + // doesn't have to pre-create the group in ASC's web UI; if + // they don't pick a name we default to the productId so + // there's *some* group rather than a hard failure — but + // surface a non-fatal warning since per-product groups + // fragment the catalog and break StoreKit 2's + // upgrade/downgrade flow between Monthly and Yearly tiers + // (those need to share a group). In dry-run, list groups + // (read-only) and report which path the real run would + // take instead of creating anything. + // + // Skip both group-resolve and create when this row already + // has a storeRef from a prior partially-successful sync — + // re-creating would either duplicate or 409 against ASC. + const groupName = row.subscriptionGroupName ?? row.productId; + if (!row.subscriptionGroupName && !row.storeRef && dryRun) { + // Surface the per-product-group warning in dry-run only + // so operators see the recommendation while previewing + // (the most common time to fix the catalog), but a + // production sync isn't blocked or noisy. Pushing into + // `failures` would also trip the markPushed gate added + // for partial-failure resilience. + plannedWrites.push({ + productId: row.productId, + step: "warning: no subscription group name set", + detail: + "Falling back to productId so this sub lands in its own group. Pick a shared name (e.g. 'Premium') for related tiers so StoreKit 2 upgrade/downgrade works.", + }); + } + let storeRef: string; + if (row.storeRef) { + storeRef = row.storeRef; + if (dryRun) { + plannedWrites.push({ + productId: row.productId, + step: "skip create (resuming partial sync)", + detail: `existing storeRef=${storeRef}`, + }); + } + } else { + let groupId: string; + if (dryRun) { + const groups = await ensureDryRunGroups(); + const existing = groups.data.find( + (g) => g.attributes.referenceName === groupName, + ); + groupId = existing?.id ?? "(would-create)"; + plannedWrites.push({ + productId: row.productId, + step: existing + ? "use existing subscription group" + : "create subscription group", + detail: groupName, + }); + storeRef = "(would-create)"; + plannedWrites.push({ + productId: row.productId, + step: "create subscription", + detail: `${row.title} · ${mapBillingPeriodToAsc(row.billingPeriod)} · group=${groupName}`, + }); + } else { + let cached = groupIdCache.get(groupName); + if (!cached) { + cached = client.findOrCreateSubscriptionGroup({ + appId: appIdStr, + referenceName: groupName, + }); + groupIdCache.set(groupName, cached); + // If the in-flight call rejects, evict the cached + // promise so a follow-up draft can retry instead of + // permanently inheriting the failure. + cached.catch(() => { + if (groupIdCache.get(groupName) === cached) { + groupIdCache.delete(groupName); + } + }); + } + groupId = await cached; + const result = await client.createSubscription({ + groupId, + productId: row.productId, + name: row.title, + subscriptionPeriod: mapBillingPeriodToAsc(row.billingPeriod), + reviewNote: row.reviewNote, + }); + storeRef = result.data.id; + // Persist the upstream id immediately so a subsequent + // step's failure doesn't lose the binding (and the + // next sync sees this row's storeRef populated and + // skips the create call above). + await ctx.runMutation(internal.products.sync.markStoreRef, { + projectId: project._id, + productId: row.productId, + platform: "IOS", + storeRef, + }); + } + } + // Localize so reviewers see the human-readable name + + // description instead of just the productId. ASC requires + // at least one locale before submission — failing here + // doesn't unwind the create (Apple has no rollback) so we + // record a failure and let the operator retry / fix in + // ASC web. + if (dryRun) { + plannedWrites.push({ + productId: row.productId, + step: "create en-US localization", + detail: row.description ?? row.title, + }); + } else { + try { + await client.createSubLocalization({ + subId: storeRef, + name: row.title, + description: row.description ?? row.title, + }); + } catch (error) { + // 409 Conflict means the en-US localization already + // exists from a prior partial sync. That's a benign + // retry — fall through to the price-setting step + // instead of marking the whole product failed. + if (!(error instanceof AscApiError && error.status === 409)) { + recordFailure({ + productId: `${row.productId} (localization)`, + reason: + error instanceof Error ? error.message : String(error), + }); + } + } + } + // Set the USA price by resolving the operator's USD amount + // → Apple's nearest price-point id. We require currency = + // "USD" because the dashboard form lets them pick others + // but we only know the USA tier ladder here; non-USD prices + // are surfaced as an actionable failure rather than silently + // mis-priced. In dry-run, skip the lookup (the just-created + // subscription resource doesn't exist for read-back) and + // just record intent. + if ( + row.priceAmountMicros !== undefined && + (row.currency ?? "USD") === "USD" + ) { + if (dryRun) { + plannedWrites.push({ + productId: row.productId, + step: "set USA price", + detail: `USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)}`, + }); + } else { + try { + const pricePointId = await client.findSubUsaPricePointId( + storeRef, + row.priceAmountMicros, + ); + if (!pricePointId) { + recordFailure({ + productId: `${row.productId} (price)`, + reason: `No ASC price tier matches USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)} — pick a published tier amount.`, + }); + } else { + await client.setSubPriceSchedule({ + subId: storeRef, + pricePointId, + }); + } + } catch (error) { + // 409 Conflict means a price schedule already exists + // for the (subscription, startDate=today) pair from a + // prior partial sync — Apple keys schedules by date, + // not by id. Treat as benign retry so the subsequent + // markPushed step still runs (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) + // review). + if (!(error instanceof AscApiError && error.status === 409)) { + recordFailure({ + productId: `${row.productId} (price)`, + reason: + error instanceof Error ? error.message : String(error), + }); + } + } + } + } else if (row.currency && row.currency !== "USD") { + recordFailure({ + productId: `${row.productId} (price)`, + reason: `Non-USD pricing (${row.currency}) not supported in push yet — set USD on the catalog row or configure other territories in ASC web.`, + }); + } + // Only flip state to Ready when every follow-up step + // succeeded. Partial setups stay in Draft (with storeRef + // populated) so the next sync resumes the missing pieces. + if (!dryRun && !rowHadFailure) { + await ctx.runMutation(internal.products.sync.markPushed, { + projectId: project._id, + productId: row.productId, + platform: "IOS", + storeRef, + }); + } + pushed += 1; + } else { + let storeRef: string; + if (row.storeRef) { + storeRef = row.storeRef; + if (dryRun) { + plannedWrites.push({ + productId: row.productId, + step: "skip create (resuming partial sync)", + detail: `existing storeRef=${storeRef}`, + }); + } + } else if (dryRun) { + storeRef = "(would-create)"; + plannedWrites.push({ + productId: row.productId, + step: "create in-app purchase", + detail: `${row.title} · ${row.type}`, + }); + } else { + const result = await client.createInAppPurchase({ + appId: appIdStr, + productId: row.productId, + name: row.title, + type: + row.type === "Consumable" ? "CONSUMABLE" : "NON_CONSUMABLE", + reviewNote: row.reviewNote, + }); + storeRef = result.data.id; + // Same partial-sync resilience as the Subscription + // branch — persist the upstream id before the + // localization / price steps that may fail. + await ctx.runMutation(internal.products.sync.markStoreRef, { + projectId: project._id, + productId: row.productId, + platform: "IOS", + storeRef, + }); + } + if (dryRun) { + plannedWrites.push({ + productId: row.productId, + step: "create en-US localization", + detail: row.description ?? row.title, + }); + } else { + try { + await client.createIapLocalization({ + iapId: storeRef, + name: row.title, + description: row.description ?? row.title, + }); + } catch (error) { + // Same 409-is-benign rationale as the subscription + // localization path — see PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review. + if (!(error instanceof AscApiError && error.status === 409)) { + recordFailure({ + productId: `${row.productId} (localization)`, + reason: + error instanceof Error ? error.message : String(error), + }); + } + } + } + if ( + row.priceAmountMicros !== undefined && + (row.currency ?? "USD") === "USD" + ) { + if (dryRun) { + plannedWrites.push({ + productId: row.productId, + step: "set USA price", + detail: `USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)}`, + }); + } else { + try { + const pricePointId = await client.findIapUsaPricePointId( + storeRef, + row.priceAmountMicros, + ); + if (!pricePointId) { + recordFailure({ + productId: `${row.productId} (price)`, + reason: `No ASC price tier matches USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)} — pick a published tier amount.`, + }); + } else { + await client.setIapPriceSchedule({ + iapId: storeRef, + pricePointId, + }); + } + } catch (error) { + // Same 409-is-benign rationale as the subscription + // price schedule path above — Apple keys IAP price + // schedules by (iapId, startDate) so a same-day + // retry hits Conflict. Allow the row to proceed to + // markPushed instead of stalling in Draft. + if (!(error instanceof AscApiError && error.status === 409)) { + recordFailure({ + productId: `${row.productId} (price)`, + reason: + error instanceof Error ? error.message : String(error), + }); + } + } + } + } else if (row.currency && row.currency !== "USD") { + recordFailure({ + productId: `${row.productId} (price)`, + reason: `Non-USD pricing (${row.currency}) not supported in push yet — set USD on the catalog row or configure other territories in ASC web.`, + }); + } + // Same gate as the Subscription branch — only flip Ready + // when no follow-up step recorded a failure for this row. + if (!dryRun && !rowHadFailure) { + await ctx.runMutation(internal.products.sync.markPushed, { + projectId: project._id, + productId: row.productId, + platform: "IOS", + storeRef, + }); + } + pushed += 1; + } + // TODO(review-submit): once Settings has an upload slot for a + // project-level App Review screenshot + // (`apple_iap_review_screenshot` purpose), add a step here: + // 1. POST /v1/inAppPurchaseAppStoreReviewScreenshots (reserve) + // 2. PUT to the returned upload URL (binary) + // 3. PATCH ...screenshots/{id} with sourceFileChecksum + // 4. POST /v1/inAppPurchaseSubmissions + // Until then, the row stops at "Ready to Submit" in ASC and + // the operator hits Submit manually (or via next app version). + } catch (error) { + recordFailure({ + productId: row.productId, + reason: error instanceof Error ? error.message : String(error), + }); + } + }; + await mapWithConcurrency(drafts, PUSH_CONCURRENCY, processOneDraft); + } + + return { + pulled, + pushed, + failures, + plannedWrites: dryRun ? plannedWrites : undefined, + }; + }, +}); + +// Lightweight read-only action so the dashboard can populate a +// subscription-group autocomplete without the operator having to copy +// reference names from ASC's web console. Returns just `{id, +// referenceName}` per group — the heavier listSubscriptionsInGroup +// fetch only happens during full pull-sync. Failures bubble back as a +// thrown Error so the dashboard can show a toast and degrade +// gracefully (the field stays a free-text input). +export const listSubscriptionGroupsAppleIOS = action({ + args: { apiKey: v.string() }, + returns: v.array(v.object({ id: v.string(), referenceName: v.string() })), + handler: async ( + ctx, + args, + ): Promise> => { + const project = await getProjectByApiKey(ctx, args.apiKey); + if (!project.iosAppAppleId) { + throw new Error("Project iosAppAppleId is not configured"); + } + const { issuerId, keyId, keyContent } = await resolveAscCredentials( + ctx, + project, + ); + const client = new AscClient(issuerId, keyId, keyContent); + const resp = await client.listSubscriptionGroups( + String(project.iosAppAppleId), + ); + return resp.data + .map((g) => ({ + id: g.id, + referenceName: g.attributes.referenceName ?? "", + })) + .filter((g) => g.referenceName.length > 0); + }, +}); + +export function mapBillingPeriodToAsc( + period: string | undefined, +): + | "ONE_WEEK" + | "ONE_MONTH" + | "TWO_MONTHS" + | "THREE_MONTHS" + | "SIX_MONTHS" + | "ONE_YEAR" { + switch (period) { + case "P1W": + return "ONE_WEEK"; + case "P1M": + case undefined: + // Treat missing billingPeriod as monthly. The catalog form + // makes billingPeriod optional and a missing value commonly + // means "I forgot to fill this in"; defaulting to monthly is + // the least destructive interpretation (the operator can fix + // the row and re-sync). + return "ONE_MONTH"; + case "P2M": + return "TWO_MONTHS"; + case "P3M": + return "THREE_MONTHS"; + case "P6M": + return "SIX_MONTHS"; + case "P1Y": + return "ONE_YEAR"; + default: + // Unknown period values used to silently coerce to ONE_MONTH, + // which provisioned the wrong subscription duration in ASC — + // a much harder-to-unwind mistake than a failed sync. Throw + // so the operator sees the typo immediately and the partial- + // failure tracking in processOneDraft records it as an + // actionable failure for that row. + throw new Error( + `Invalid billing period for ASC subscription: "${period}". ` + + `Expected one of P1W, P1M, P2M, P3M, P6M, P1Y (or omit for monthly).`, + ); + } +} + +function mapAscIapType( + raw: string | undefined, +): "Subscription" | "NonConsumable" | "Consumable" { + switch (raw) { + case "CONSUMABLE": + return "Consumable"; + case "NON_RENEWING_SUBSCRIPTION": + case "NON_CONSUMABLE": + return "NonConsumable"; + default: + return "NonConsumable"; + } +} + +// Apple represents introductory-offer durations as enum strings +// rather than ISO-8601 like the subscriptionPeriod field. Translate +// to ISO so kit's `offers[].duration` is uniform across stores +// (Play already uses ISO `P1W` / `P1M` / etc.). Unknown values fall +// through as-is so the dashboard can still render whatever Apple +// returned even if Apple ships a new enum value. +export function mapAscOfferDurationToIso( + raw: string | undefined, +): string | undefined { + if (!raw) return undefined; + switch (raw) { + case "THREE_DAYS": + return "P3D"; + case "ONE_WEEK": + return "P1W"; + case "TWO_WEEKS": + return "P2W"; + case "ONE_MONTH": + return "P1M"; + case "TWO_MONTHS": + return "P2M"; + case "THREE_MONTHS": + return "P3M"; + case "SIX_MONTHS": + return "P6M"; + case "ONE_YEAR": + return "P1Y"; + default: + return raw; + } +} + +export function mapAscOfferKind( + mode: string | undefined, +): "FreeTrial" | "IntroPayUpFront" | "IntroPayAsYouGo" { + switch (mode) { + case "PAY_UP_FRONT": + return "IntroPayUpFront"; + case "PAY_AS_YOU_GO": + return "IntroPayAsYouGo"; + case "FREE_TRIAL": + default: + return "FreeTrial"; + } +} + +// Convert ASC introductory offers list into kit's `offers[]` shape. +// Picks rows whose date range covers today (consistent with how +// `pickActivePriceRow` resolves the active price). Free-trial offers +// have no pricePoint — we emit them with no priceAmountMicros. +export function parseIntroOffers( + resp: AscIntroOfferListResponse | null, +): Array<{ + id: string; + kind: "FreeTrial" | "IntroPayUpFront" | "IntroPayAsYouGo"; + duration?: string; + numberOfPeriods?: number; + priceAmountMicros?: number; + currency?: string; +}> { + if (!resp || resp.data.length === 0) return []; + const today = new Date().toISOString().slice(0, 10); + return resp.data + .filter((row) => { + const start = row.attributes?.startDate ?? null; + const end = row.attributes?.endDate ?? null; + if (start && start > today) return false; + if (end && end < today) return false; + return true; + }) + .map((row) => { + const pointId = row.relationships?.subscriptionPricePoint?.data?.id; + const point = pointId + ? resp.included?.find((entry) => entry.id === pointId) + : undefined; + const raw = point?.attributes?.customerPrice; + const n = raw ? Number(raw) : Number.NaN; + const priceAmountMicros = Number.isFinite(n) + ? Math.round(n * 1_000_000) + : undefined; + return { + id: row.id, + kind: mapAscOfferKind(row.attributes?.offerMode), + duration: mapAscOfferDurationToIso(row.attributes?.duration), + numberOfPeriods: row.attributes?.numberOfPeriods, + priceAmountMicros, + currency: priceAmountMicros !== undefined ? "USD" : undefined, + }; + }); +} + +function mapAscState( + raw: string | undefined, +): "Draft" | "Ready" | "Active" | "Removed" { + switch (raw) { + case "WAITING_FOR_REVIEW": + case "PENDING_DEVELOPER_RELEASE": + case "READY_TO_SUBMIT": + return "Ready"; + case "APPROVED": + case "REPLACED": + return "Active"; + case "DEVELOPER_REMOVED_FROM_SALE": + case "REMOVED_FROM_SALE": + return "Removed"; + default: + return "Draft"; + } +} diff --git a/packages/kit/convex/products/jwt.test.ts b/packages/kit/convex/products/jwt.test.ts new file mode 100644 index 00000000..61a52474 --- /dev/null +++ b/packages/kit/convex/products/jwt.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { + generateKeyPairSync, + createVerify, + createPrivateKey, + createPublicKey, +} from "node:crypto"; +import { derSignatureToJoseSignature, mintAscJwt } from "./jwt"; + +function generateP8() { + const { privateKey } = generateKeyPairSync("ec", { + namedCurve: "P-256", + }); + return privateKey.export({ format: "pem", type: "pkcs8" }).toString(); +} + +describe("mintAscJwt", () => { + it("mints a 3-segment JWT with ES256 header and ASC audience", () => { + const pem = generateP8(); + const token = mintAscJwt({ + keyId: "ABCD1234", + privateKey: pem, + issuerId: "00000000-0000-0000-0000-aaaaaaaaaaaa", + nowSeconds: () => 1_711_000_000, + }); + + const parts = token.split("."); + expect(parts).toHaveLength(3); + const header = JSON.parse( + Buffer.from(parts[0], "base64url").toString("utf-8"), + ); + expect(header).toEqual({ alg: "ES256", kid: "ABCD1234", typ: "JWT" }); + + const payload = JSON.parse( + Buffer.from(parts[1], "base64url").toString("utf-8"), + ); + expect(payload.iss).toBe("00000000-0000-0000-0000-aaaaaaaaaaaa"); + expect(payload.aud).toBe("appstoreconnect-v1"); + expect(payload.iat).toBe(1_711_000_000); + expect(payload.exp).toBe(1_711_000_000 + 600); + }); + + it("respects custom ttlSeconds", () => { + const pem = generateP8(); + const token = mintAscJwt({ + keyId: "X", + privateKey: pem, + issuerId: "iss", + ttlSeconds: 1_200, + nowSeconds: () => 1, + }); + const payload = JSON.parse( + Buffer.from(token.split(".")[1], "base64url").toString("utf-8"), + ); + expect(payload.exp - payload.iat).toBe(1_200); + }); + + it("produces a signature that verifies against the public key with the JOSE r||s format", () => { + const pem = generateP8(); + const token = mintAscJwt({ + keyId: "k", + privateKey: pem, + issuerId: "iss", + }); + + const [headerB64, payloadB64, sigB64] = token.split("."); + const signingInput = `${headerB64}.${payloadB64}`; + const joseSig = Buffer.from(sigB64, "base64url"); + expect(joseSig.length).toBe(64); + + // Convert back to DER for node verifier. + const r = bigIntFrom(joseSig.subarray(0, 32)); + const s = bigIntFrom(joseSig.subarray(32)); + const der = encodeDerSignature(r, s); + + const privateKey = createPrivateKey({ key: pem, format: "pem" }); + const publicKey = createPublicKey(privateKey) + .export({ format: "pem", type: "spki" }) + .toString(); + const verifier = createVerify("SHA256"); + verifier.update(signingInput); + verifier.end(); + expect(verifier.verify(publicKey, der)).toBe(true); + }); +}); + +describe("derSignatureToJoseSignature", () => { + it("strips DER framing and left-pads r,s to fixed coord size", () => { + // DER for r=0x01, s=0x02 ECDSA over P-256. + // SEQUENCE 06 02 01 01 02 01 02 + const der = Buffer.from([0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02]); + const jose = derSignatureToJoseSignature(der, 32); + expect(jose.length).toBe(64); + // r should be 31 zero bytes followed by 0x01 + expect(jose[31]).toBe(0x01); + expect(jose[63]).toBe(0x02); + }); +}); + +function bigIntFrom(buf: Buffer): Buffer { + // Strip excess leading zeros so the integer is canonical, then add a + // 0x00 prefix back if the high bit of the leading nonzero byte is + // set (DER says positive integers can't start with 0x80+). The prior + // version stripped leading zeros AFTER checking the high bit and + // missed the `00 80 ...` case — that pattern occurs ~1/65536 times + // per coord, which made the ECDSA round-trip test flake on CI. + let i = 0; + while (i < buf.length - 1 && buf[i] === 0) i += 1; + const stripped = buf.subarray(i); + if (((stripped[0] ?? 0) & 0x80) !== 0) { + return Buffer.concat([Buffer.from([0x00]), stripped]); + } + return stripped; +} + +function encodeDerSignature(r: Buffer, s: Buffer): Buffer { + const rPart = Buffer.concat([Buffer.from([0x02, r.length]), r]); + const sPart = Buffer.concat([Buffer.from([0x02, s.length]), s]); + const inner = Buffer.concat([rPart, sPart]); + return Buffer.concat([Buffer.from([0x30, inner.length]), inner]); +} diff --git a/packages/kit/convex/products/jwt.ts b/packages/kit/convex/products/jwt.ts new file mode 100644 index 00000000..fe6a902f --- /dev/null +++ b/packages/kit/convex/products/jwt.ts @@ -0,0 +1,174 @@ +"use node"; +// Minimal ES256 JWT minter for App Store Connect API authentication. +// ASC requires every request to carry a JWT in `Authorization: Bearer` +// signed with the project's downloaded `.p8` key (kid + issuerId). +// +// We do NOT reach for `jose` / `jsonwebtoken` here — both pull +// substantial node-only dependency trees into the Convex action +// bundle, and ASC's JWT shape is tiny (3 fields + ES256 over the +// canonical SHA-256 of the header.payload bytes). node:crypto on Bun +// already supports raw ECDSA over P-256. +// +// Pure helpers only; no Convex imports so this is unit-testable in +// vitest without an action runtime. + +import { createPrivateKey, createSign } from "node:crypto"; + +export type AscJwtClaims = { + iss: string; // issuerId — ASC > Users and Access > Keys + scope?: string[]; // optional ASC scope claim + // aud is fixed to "appstoreconnect-v1" by ASC. + // iat / exp are computed from `nowSeconds`. +}; + +export type AscJwtOptions = { + keyId: string; // ASC > Keys > Key ID + privateKey: string; // PKCS#8 PEM (the .p8 file content) + issuerId: string; + // Token TTL in seconds. ASC enforces ≤ 1200s (20 min); default to a + // conservative 600s to leave headroom for clock skew. + ttlSeconds?: number; + nowSeconds?: () => number; // injected for tests +}; + +export function mintAscJwt(opts: AscJwtOptions): string { + const ttl = opts.ttlSeconds ?? 600; + const now = opts.nowSeconds + ? opts.nowSeconds() + : Math.floor(Date.now() / 1000); + + const header = { + alg: "ES256", + kid: opts.keyId, + typ: "JWT", + }; + const payload: Record = { + iss: opts.issuerId, + iat: now, + exp: now + ttl, + aud: "appstoreconnect-v1", + }; + + const headerB64 = base64UrlEncode(Buffer.from(JSON.stringify(header))); + const payloadB64 = base64UrlEncode(Buffer.from(JSON.stringify(payload))); + const signingInput = `${headerB64}.${payloadB64}`; + + const keyObj = createPrivateKey({ + key: opts.privateKey, + format: "pem", + }); + const signer = createSign("SHA256"); + signer.update(signingInput); + signer.end(); + const derSignature = signer.sign(keyObj); + + // node:crypto signs in DER. ASC requires the JWS-flavored r||s + // concatenation — convert. + const jwsSignature = derSignatureToJoseSignature(derSignature, 32); + return `${signingInput}.${base64UrlEncode(jwsSignature)}`; +} + +function base64UrlEncode(buf: Buffer | Uint8Array): string { + return Buffer.from(buf) + .toString("base64") + .replace(/=+$/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +// DER-encoded ECDSA signature is `SEQUENCE { INTEGER r, INTEGER s }`. +// JWS expects fixed-length r||s (each `coordSize` bytes). Strip the +// leading 0x00 padding nodes adds for unsigned-positive encoding, then +// left-pad each integer back out to coordSize. +// +// Bounds checks on every read: `node:crypto` always emits well-formed +// DER, but a future caller passing an arbitrary buffer (e.g. user- +// supplied signature blob from a webhook) without validation could +// otherwise trigger out-of-range subarrays / silent NaN-style reads. +// Each guard throws with a consistent shape so the caller can wrap a +// single try/catch instead of branching on byte-level corruption. +export function derSignatureToJoseSignature( + der: Buffer | Uint8Array, + coordSize: number, +): Buffer { + const buf = Buffer.from(der); + // Minimum legal DER ECDSA signature is SEQUENCE + len + INTEGER + len + // + 1-byte r + INTEGER + len + 1-byte s = 8 bytes. Anything shorter + // can't possibly be valid; bailing here also makes the indexing + // below safe to do. + if (buf.length < 8) { + throw new Error( + `Invalid DER signature: buffer too short (${buf.length} bytes, need >= 8)`, + ); + } + if (buf[0] !== 0x30) { + throw new Error("Invalid DER signature: missing SEQUENCE tag"); + } + let offset = 2; + if ((buf[1] ?? 0) & 0x80) { + // long-form length — uncommon at this size but legal. + const lenBytes = (buf[1] ?? 0) & 0x7f; + offset = 2 + lenBytes; + if (offset > buf.length) { + throw new Error( + "Invalid DER signature: long-form length declares more bytes than the buffer holds", + ); + } + } + if (offset + 1 >= buf.length) { + throw new Error( + "Invalid DER signature: truncated before first INTEGER tag", + ); + } + if (buf[offset] !== 0x02) { + throw new Error("Invalid DER signature: missing first INTEGER tag"); + } + const rLen = buf[offset + 1] ?? 0; + const rEnd = offset + 2 + rLen; + if (rEnd > buf.length) { + throw new Error( + `Invalid DER signature: r INTEGER (${rLen} bytes) extends past buffer end`, + ); + } + const r = buf.subarray(offset + 2, rEnd); + offset = rEnd; + if (offset + 1 >= buf.length) { + throw new Error( + "Invalid DER signature: truncated before second INTEGER tag", + ); + } + if (buf[offset] !== 0x02) { + throw new Error("Invalid DER signature: missing second INTEGER tag"); + } + const sLen = buf[offset + 1] ?? 0; + const sEnd = offset + 2 + sLen; + if (sEnd > buf.length) { + throw new Error( + `Invalid DER signature: s INTEGER (${sLen} bytes) extends past buffer end`, + ); + } + const s = buf.subarray(offset + 2, sEnd); + + return Buffer.concat([ + leftPad(stripLeadingZeros(r), coordSize), + leftPad(stripLeadingZeros(s), coordSize), + ]); +} + +function stripLeadingZeros(buf: Buffer): Buffer { + let i = 0; + while (i < buf.length - 1 && buf[i] === 0) i += 1; + return buf.subarray(i); +} + +function leftPad(buf: Buffer, size: number): Buffer { + if (buf.length === size) return buf; + if (buf.length > size) { + throw new Error( + `signature component is ${buf.length} bytes — cannot fit into ${size}`, + ); + } + const out = Buffer.alloc(size); + buf.copy(out, size - buf.length); + return out; +} diff --git a/packages/kit/convex/products/mutation.ts b/packages/kit/convex/products/mutation.ts new file mode 100644 index 00000000..84e1cc82 --- /dev/null +++ b/packages/kit/convex/products/mutation.ts @@ -0,0 +1,219 @@ +import { mutation } from "../_generated/server"; +import { v } from "convex/values"; +import type { Doc } from "../_generated/dataModel"; + +const platformValidator = v.union(v.literal("IOS"), v.literal("Android")); +const typeValidator = v.union( + v.literal("Subscription"), + v.literal("NonConsumable"), + v.literal("Consumable"), +); +const stateValidator = v.union( + v.literal("Draft"), + v.literal("Ready"), + v.literal("Active"), + v.literal("Removed"), +); + +// Public mutation: upsert a product in kit's catalog. Authoritative +// state lives in App Store Connect / Play Console; this row is a +// kit-side cache so the dashboard, MCP server, and SDKs share one +// canonical view. Phase 3 follow-ups will add ASC / Play push-sync +// — until then, treat this as a hand-managed catalog. +export const upsertProduct = mutation({ + args: { + apiKey: v.string(), + productId: v.string(), + platform: platformValidator, + type: typeValidator, + title: v.string(), + description: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + currency: v.optional(v.string()), + billingPeriod: v.optional( + v.union( + v.literal("P1W"), + v.literal("P1M"), + v.literal("P2M"), + v.literal("P3M"), + v.literal("P6M"), + v.literal("P1Y"), + ), + ), + subscriptionGroupName: v.optional(v.string()), + reviewNote: v.optional(v.string()), + state: v.optional(stateValidator), + storeRef: v.optional(v.string()), + }, + returns: v.object({ + id: v.id("products"), + created: v.boolean(), + }), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + if (!project) throw new Error("Invalid API key"); + + // Reject negative prices. The catalog row would otherwise round- + // trip into push-sync (asc.ts / play.ts) and either crash on + // Apple's price-tier lookup or land a negative `priceMicros` on + // Play, neither of which the operator can correct from the + // dashboard later (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + if (args.priceAmountMicros !== undefined && args.priceAmountMicros < 0) { + throw new Error("priceAmountMicros must be non-negative"); + } + + // iOS subscriptions REQUIRE a subscriptionGroupName upstream — + // related tiers must share a group for StoreKit 2's native + // upgrade/downgrade UI to work. The Apple push-sync (asc.ts) + // falls back to using the productId as the group name when this + // is missing, which results in each subscription landing in its + // own fragmented group and silently breaks the upgrade flow. + // Reject the upsert before that drift can happen so the operator + // gets a loud, actionable error instead of a broken store + // experience two sync passes later (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + if ( + args.platform === "IOS" && + args.type === "Subscription" && + (!args.subscriptionGroupName || !args.subscriptionGroupName.trim()) + ) { + throw new Error( + "subscriptionGroupName is required for iOS Subscription products — related tiers must share a group for StoreKit 2 upgrade/downgrade to work. Pick a group name (e.g. 'premium_tiers') and reuse it for every related subscription. kit's push-sync (asc.ts) will create the group in App Store Connect on first push and reuse the existing group on subsequent pushes if a group with the same name already exists upstream — you do not have to create it in ASC manually first.", + ); + } + + const existing: Doc<"products"> | null = await ctx.db + .query("products") + .withIndex("by_project_and_platform_and_product", (q) => + q + .eq("projectId", project._id) + .eq("platform", args.platform) + .eq("productId", args.productId), + ) + .unique(); + + const now = Date.now(); + if (existing) { + // State-only flips moved to `setProductState`. This mutation + // now treats every supplied field as authoritative — keeping + // the prior "blank title preserves existing" hack would still + // mask cases where a caller really did mean to clear a field. + await ctx.db.patch(existing._id, { + type: args.type, + title: args.title, + description: args.description ?? existing.description, + priceAmountMicros: args.priceAmountMicros ?? existing.priceAmountMicros, + currency: args.currency ?? existing.currency, + billingPeriod: args.billingPeriod ?? existing.billingPeriod, + subscriptionGroupName: + args.subscriptionGroupName ?? existing.subscriptionGroupName, + reviewNote: args.reviewNote ?? existing.reviewNote, + state: args.state ?? existing.state, + storeRef: args.storeRef ?? existing.storeRef, + updatedAt: now, + }); + return { id: existing._id, created: false }; + } + + const id = await ctx.db.insert("products", { + projectId: project._id, + productId: args.productId, + platform: args.platform, + type: args.type, + title: args.title, + description: args.description, + priceAmountMicros: args.priceAmountMicros, + currency: args.currency, + billingPeriod: args.billingPeriod, + subscriptionGroupName: args.subscriptionGroupName, + reviewNote: args.reviewNote, + state: args.state ?? "Draft", + storeRef: args.storeRef, + updatedAt: now, + }); + return { id, created: true }; + }, +}); + +// State-only mutation used by `manage_product` (MCP) and the +// dashboard's enable/disable affordance. Distinct from `upsertProduct` +// because the previous reuse pattern (passing a blank title + +// hardcoded type so only `state` would update) would silently +// overwrite the existing row's `type` — e.g. flipping a NonConsumable +// to Subscription. Splitting the mutation prevents that class of +// drive-by clobber. +export const setProductState = mutation({ + args: { + apiKey: v.string(), + productId: v.string(), + platform: platformValidator, + state: stateValidator, + }, + returns: v.object({ + id: v.id("products"), + state: stateValidator, + }), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + if (!project) throw new Error("Invalid API key"); + + const existing = await ctx.db + .query("products") + .withIndex("by_project_and_platform_and_product", (q) => + q + .eq("projectId", project._id) + .eq("platform", args.platform) + .eq("productId", args.productId), + ) + .unique(); + if (!existing) throw new Error("Product not found"); + + await ctx.db.patch(existing._id, { + state: args.state, + updatedAt: Date.now(), + }); + return { id: existing._id, state: args.state }; + }, +}); + +export const removeProduct = mutation({ + args: { + apiKey: v.string(), + productId: v.string(), + platform: platformValidator, + }, + returns: v.object({ ok: v.boolean() }), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + if (!project) return { ok: false }; + + const existing = await ctx.db + .query("products") + .withIndex("by_project_and_platform_and_product", (q) => + q + .eq("projectId", project._id) + .eq("platform", args.platform) + .eq("productId", args.productId), + ) + .unique(); + if (!existing) return { ok: false }; + + // Soft-remove via state flag — keeps audit history for the + // dashboard and preserves any webhook events that reference this productId. + await ctx.db.patch(existing._id, { + state: "Removed", + updatedAt: Date.now(), + }); + return { ok: true }; + }, +}); diff --git a/packages/kit/convex/products/play.test.ts b/packages/kit/convex/products/play.test.ts new file mode 100644 index 00000000..1041319e --- /dev/null +++ b/packages/kit/convex/products/play.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { basePlanIdForPeriod, moneyToMicros } from "./play"; + +describe("moneyToMicros", () => { + it("returns undefined when input is missing or has no units", () => { + expect(moneyToMicros(undefined)).toBeUndefined(); + expect(moneyToMicros({ currencyCode: "USD" })).toBeUndefined(); + }); + + it("converts whole dollars (units only) to micros", () => { + expect(moneyToMicros({ currencyCode: "USD", units: "9", nanos: 0 })).toBe( + 9_000_000, + ); + }); + + it("converts units + nanos combination correctly", () => { + // $9.99 = units 9 + nanos 990_000_000 → 9_990_000 micros + expect( + moneyToMicros({ currencyCode: "USD", units: "9", nanos: 990_000_000 }), + ).toBe(9_990_000); + }); + + it("truncates nanos / 1000 conversion (sub-micro fraction is dropped, not rounded up)", () => { + // 999_999_999 nanos / 1000 = 999_999.999 → truncates to 999_999 + // micros. We deliberately don't round up to 1_000_000; rounding + // would silently push prices across the unit boundary (PR #124 (https://github.com/hyodotdev/openiap/pull/124) + // review — "999_999_999 nanos rounding up to a full unit"), and + // Play stores prices in micros internally so truncation matches + // the canonical representation. + expect( + moneyToMicros({ currencyCode: "USD", units: "0", nanos: 999_999_999 }), + ).toBe(999_999); + }); + + it("uses BigInt math to preserve precision up to Number.MAX_SAFE_INTEGER", () => { + // 9_007_199_254 KRW is the largest unit value that, multiplied by + // 1_000_000 (micros), stays at or below Number.MAX_SAFE_INTEGER + // (9_007_199_254_740_992). Beyond this the new guard correctly + // returns undefined to avoid silent IEEE 754 truncation. + expect( + moneyToMicros({ currencyCode: "KRW", units: "9007199254", nanos: 0 }), + ).toBe(9_007_199_254_000_000); + }); + + it("returns undefined when the converted micros exceed Number.MAX_SAFE_INTEGER", () => { + // 1e10 KRW * 1_000_000 micros > 2^53 — the schema stores + // priceAmountMicros as a JS number (double), so anything past + // the safe range would silently round-trip to a corrupted value. + // The guard surfaces "price unknown" so the dashboard can show + // an affordance instead of a wrong number. + expect( + moneyToMicros({ currencyCode: "KRW", units: "10000000000", nanos: 0 }), + ).toBeUndefined(); + }); + + it("returns undefined when units is not a parseable BigInt string", () => { + expect( + moneyToMicros({ currencyCode: "USD", units: "abc", nanos: 0 }), + ).toBeUndefined(); + }); +}); + +describe("basePlanIdForPeriod", () => { + it.each([ + ["P1W", "weekly"], + ["P1M", "monthly"], + ["P2M", "bimonthly"], + ["P3M", "quarterly"], + ["P6M", "semiannual"], + ["P1Y", "yearly"], + ])("maps %s → %s", (iso, label) => { + expect(basePlanIdForPeriod(iso)).toBe(label); + }); + + it("falls back to monthly for undefined / unknown periods", () => { + expect(basePlanIdForPeriod(undefined)).toBe("monthly"); + expect(basePlanIdForPeriod("P9X")).toBe("monthly"); + }); +}); diff --git a/packages/kit/convex/products/play.ts b/packages/kit/convex/products/play.ts new file mode 100644 index 00000000..bc4ebe68 --- /dev/null +++ b/packages/kit/convex/products/play.ts @@ -0,0 +1,963 @@ +"use node"; +import { v } from "convex/values"; +import { google } from "googleapis"; +import type { androidpublisher_v3 } from "googleapis"; + +import { action } from "../_generated/server"; +import { internal } from "../_generated/api"; +import { getProjectByApiKey } from "../purchases/shared"; +import { coerceBillingPeriod } from "./sync"; + +/** + * Per-product upstream rejection reported back to the dashboard. Used + * inside `pushSyncProductsGoogle`'s `failures` array; extracted so the + * shape stays in lockstep across every site that pushes into it. + */ +export interface ProductSyncFailure { + productId: string; + reason: string; +} + +// Google Play Developer API client + push-sync action. +// +// Auth: reuses the same per-project service-account JSON kit already +// stores for receipt verification (see `convex/purchases/android.ts`). +// The googleapis SDK handles OAuth token minting. +// +// Surface area: +// - inappproducts.list → kit ← Play one-time products +// - inappproducts.get +// - inappproducts.insert → kit → Play (create new) +// - inappproducts.patch → kit → Play (update existing) +// - monetization.subscriptions.list/insert → subscription products +// The `pushSyncProductsGoogle` action drives both directions. + +/** + * Pull, push, or two-way sync the project's product catalog with + * Google Play's Android Publisher API. + * + * `direction = "pull"`: import every IAP / subscription that exists + * upstream into kit. `direction = "push"`: promote every kit-side row + * with `state: "Draft"` to Play. `direction = "both"` (default): pull + * first, then push so the catalog converges. + * + * NOTE on action duration: same caveat as `pushSyncProductsAppleIOS` + * — this handler walks the project's catalog sequentially with + * per-page Promise.all fan-out. Convex actions have a 10-minute hard + * ceiling. Typical commercial apps (<100 SKUs) finish well inside that + * bound; catalogs >500 SKUs may need a batched + scheduler-chained + * variant. Tracked as a follow-up. + * + * @returns Counts of `pulled` / `pushed` rows plus a `failures` list + * carrying per-product upstream rejection reasons so the + * dashboard can render them. + */ +export const pushSyncProductsGoogle = action({ + args: { + apiKey: v.string(), + direction: v.optional( + v.union(v.literal("pull"), v.literal("push"), v.literal("both")), + ), + }, + returns: v.object({ + pulled: v.number(), + pushed: v.number(), + failures: v.array(v.object({ productId: v.string(), reason: v.string() })), + }), + handler: async ( + ctx, + args, + ): Promise<{ + pulled: number; + pushed: number; + failures: ProductSyncFailure[]; + }> => { + const project = await getProjectByApiKey(ctx, args.apiKey); + if (!project.androidPackageName) { + throw new Error("Project androidPackageName is not configured"); + } + + const serviceAccountFile = await ctx.runQuery( + internal.files.internal.getGooglePlayFileByProjectInternal, + { projectId: project._id }, + ); + if (!serviceAccountFile) { + throw new Error( + "Google Play service account JSON not found — upload it before running push-sync", + ); + } + const fileContent = await ctx.runAction( + internal.files.internal.readFileAsText, + { fileId: serviceAccountFile._id }, + ); + if (!fileContent?.content) { + throw new Error("Service account JSON file is unreadable"); + } + // Wrap the parse so a malformed JSON upload yields an actionable + // config error ("Service account JSON is invalid") instead of a + // raw SyntaxError from JSON.parse, which surfaces as a generic + // 500 with no operator-friendly hint. + let credentials: Record; + try { + credentials = JSON.parse(fileContent.content) as Record; + } catch { + throw new Error( + "Service account JSON is invalid — re-upload the file from Google Cloud Console", + ); + } + + const auth = new google.auth.GoogleAuth({ + credentials, + scopes: ["https://www.googleapis.com/auth/androidpublisher"], + }); + const androidpublisher = google.androidpublisher({ version: "v3", auth }); + const packageName = project.androidPackageName; + const direction = args.direction ?? "both"; + const failures: ProductSyncFailure[] = []; + let pulled = 0; + let pushed = 0; + + // ── PULL: Play → kit ───────────────────────────────────────── + if (direction === "pull" || direction === "both") { + // One-time products. Play has TWO catalog APIs and apps live in + // different ones depending on when/how they were set up: + // + // - `inappproducts.list` — legacy v1 endpoint. Apps created + // before the new monetization framework store products here. + // - `monetization.onetimeproducts.list` — new endpoint. Apps + // onboarded under "Manage products" in the modern Play + // Console store products HERE and `inappproducts.list` + // silently returns empty for them. (This is why "Sync with + // Play Console" was only pulling subscriptions for accounts + // using the new console — the one-time products were + // invisible to the legacy endpoint.) + // + // We hit both, dedupe by SKU, and keep going on either failing + // — that way an account that lives entirely in one or the other + // still gets a complete pull instead of failing on the missing + // half. + // + // ORDER MATTERS: hit the new monetization API first so its + // USD-preferred regional price wins. The legacy + // `inappproducts.list` only exposes a single `defaultPrice` + // (whatever currency the merchant set in Play Console — often + // their home currency) which made products like + // `dev.hyo.martie.10bulbs` show up as "AED 3.89" on the + // dashboard for an operator using a Korean Play Console where + // AED happens to be a regional override. New endpoint runs + // first; legacy only fills in skus the new endpoint missed. + const seenOneTimeSkus = new Set(); + try { + // Defensive guard: the new monetization API isn't surfaced in + // any typed shape by `googleapis` yet, so we cast through + // `unknown` and read the (possibly-missing) `onetimeproducts` + // property. `androidpublisher.monetization` is documented but + // could change shape in a future SDK release; failing soft + // (treating it as "no monetization endpoint here") lets the + // legacy `inappproducts.list` path below still pull what it + // can instead of bailing the entire pull half-done. The + // outer try/catch records the failure in the per-product + // `failures` array so the operator sees something happened. + const monetizationApi = androidpublisher.monetization as + | { onetimeproducts?: unknown } + | undefined; + const onetime = ( + monetizationApi as unknown as { + onetimeproducts?: { + list: (params: { + packageName: string; + pageToken?: string; + }) => Promise<{ + data: { + oneTimeProducts?: Array<{ + productId?: string; + listings?: Array<{ + languageCode?: string; + title?: string; + description?: string; + }>; + purchaseOptions?: Array<{ + state?: string; + purchaseOptionId?: string; + buyOption?: { legacyCompatible?: boolean }; + rentOption?: unknown; + // Pricing lives DIRECTLY on the purchaseOption, + // NOT nested inside buyOption. The earlier shape + // (buyOption.regionalPricingAndAvailabilityConfigs) + // was wrong — every one-time product surfaced + // with no price because the lookup never matched. + regionalPricingAndAvailabilityConfigs?: Array<{ + regionCode?: string; + // Google's enum: "AVAILABLE", + // "NO_LONGER_AVAILABLE", "AVAILABLE_IF_RELEASED". + // Stale rows from removed regions still ship + // back with a price attached, so without this + // field we'd happily display a price the + // operator turned off years ago. + availability?: string; + price?: { + currencyCode?: string; + units?: string; + nanos?: number; + }; + }>; + }>; + }>; + nextPageToken?: string; + }; + }>; + }; + } + ).onetimeproducts; + if (onetime?.list) { + let token: string | undefined; + let pageCount = 0; + do { + const resp = await onetime.list({ + packageName, + ...(token ? { pageToken: token } : {}), + }); + for (const product of resp.data.oneTimeProducts ?? []) { + if (!product.productId) continue; + if (seenOneTimeSkus.has(product.productId)) continue; + seenOneTimeSkus.add(product.productId); + const listing = product.listings?.[0]; + // Walk every purchaseOption × regionalPricingAndAvailabilityConfig + // (pricing lives on the option, not inside buyOption). + // Two filters before ranking: + // - drop regions explicitly NO_LONGER_AVAILABLE so we + // don't surface stale pricing the operator removed. + // - require the price to have a `units` field — Google + // ships zero-priced placeholder rows for some regions + // and they'd outrank real prices alphabetically. + // Ranking: regionCode === "US" first (canonical kit + // display currency, deterministically maps to USD), + // then any USD-currency region (covers operators who + // override the US region price into a non-USD currency + // — rare but possible), then the first remaining region + // alphabetically by currency for a stable result. + const priceCandidates: Array<{ + regionCode?: string; + currencyCode?: string; + units?: string; + nanos?: number; + }> = []; + for (const opt of product.purchaseOptions ?? []) { + for (const region of opt.regionalPricingAndAvailabilityConfigs ?? + []) { + if (region.availability === "NO_LONGER_AVAILABLE") continue; + if (region.price && typeof region.price.units === "string") { + priceCandidates.push({ + regionCode: region.regionCode, + currencyCode: region.price.currencyCode, + units: region.price.units, + nanos: region.price.nanos, + }); + } + } + } + priceCandidates.sort((a, b) => + (a.currencyCode ?? "").localeCompare(b.currencyCode ?? ""), + ); + const preferred = + priceCandidates.find((p) => p.regionCode === "US") ?? + priceCandidates.find((p) => p.currencyCode === "USD") ?? + priceCandidates[0]; + const priceAmountMicros = preferred + ? moneyToMicros({ + units: preferred.units, + nanos: preferred.nanos, + }) + : undefined; + await ctx.runMutation(internal.products.sync.upsertFromStore, { + projectId: project._id, + productId: product.productId, + platform: "Android", + // The new API doesn't carry a "consumable vs. + // non-consumable" distinction the same way — Play + // tracks consumption at purchase time. Default to + // NonConsumable; operators can edit on the kit side. + type: "NonConsumable", + title: listing?.title ?? product.productId, + description: listing?.description ?? undefined, + priceAmountMicros, + currency: preferred?.currencyCode ?? undefined, + storeRef: product.productId, + state: "Active", + }); + pulled += 1; + } + token = resp.data.nextPageToken ?? undefined; + pageCount += 1; + if (pageCount > 50) break; + } while (token); + } + } catch (error) { + failures.push({ + productId: "(play list onetimeproducts)", + reason: error instanceof Error ? error.message : String(error), + }); + } + + // Legacy `inappproducts.list` runs SECOND so any sku already + // surfaced by the new endpoint (with USD-preferred pricing) wins + // via the dedupe set. Only skus invisible to the new endpoint + // get filled in here with whatever `defaultPrice` the merchant + // set in Play Console. + try { + let token: string | undefined; + let pageCount = 0; + do { + const oneTimes = await androidpublisher.inappproducts.list({ + packageName, + ...(token ? { token } : {}), + }); + for (const product of oneTimes.data.inappproduct ?? []) { + if (!product.sku) continue; + if (seenOneTimeSkus.has(product.sku)) continue; + seenOneTimeSkus.add(product.sku); + if (product.purchaseType === "subscription") continue; + await ctx.runMutation(internal.products.sync.upsertFromStore, { + projectId: project._id, + productId: product.sku, + platform: "Android", + type: mapPlayOneTimeType(product), + title: pickPlayTitle(product) ?? product.sku, + description: pickPlayDescription(product), + priceAmountMicros: parsePlayPriceMicros(product), + currency: pickPlayCurrency(product), + storeRef: product.sku, + state: mapPlayStatus(product.status), + }); + pulled += 1; + } + token = oneTimes.data.tokenPagination?.nextPageToken ?? undefined; + pageCount += 1; + if (pageCount > 50) break; + } while (token); + } catch (error) { + // The legacy `inappproducts.list` endpoint is deprecated for + // newer Play Console accounts and Google now responds with + // "Please migrate to the new publishing API". That message is + // expected — the new `monetization.onetimeproducts.list` call + // above already covers this account — and surfacing it as a + // failure produces a noisy red toast every Sync. Suppress it + // when seen; surface anything else. + const reason = error instanceof Error ? error.message : String(error); + if (!/migrate to the new publishing API/i.test(reason)) { + failures.push({ + productId: "(play list inappproducts)", + reason, + }); + } + } + + try { + let token: string | undefined; + let pageCount = 0; + do { + const subs = await androidpublisher.monetization.subscriptions.list({ + packageName, + ...(token ? { pageToken: token } : {}), + }); + for (const sub of subs.data.subscriptions ?? []) { + if (!sub.productId) continue; + const { priceAmountMicros, currency, basePlanId } = + pickSubBasePlanPrice(sub); + const offers = collectPlaySubscriptionOffers(sub); + // Pick the billingPeriod from the *same* base plan whose + // price we just selected (`basePlanId` returned by + // pickSubBasePlanPrice). If we can't find that exact plan + // in `offers`, fall back to the first BasePlan row — but + // this fallback only triggers when basePlanId is missing, + // which means the subscription has no price at all. + // Without the basePlanId match, mixed monthly + yearly + // products would pair the yearly USD price with the + // monthly duration and break MRR normalization. + const billingPeriod = ( + basePlanId + ? offers.find( + (o) => o.kind === "BasePlan" && o.id === basePlanId, + ) + : offers.find((o) => o.kind === "BasePlan") + )?.duration; + await ctx.runMutation(internal.products.sync.upsertFromStore, { + projectId: project._id, + productId: sub.productId, + platform: "Android", + type: "Subscription", + title: sub.listings?.[0]?.title ?? sub.productId, + description: sub.listings?.[0]?.description ?? undefined, + priceAmountMicros, + currency, + storeRef: sub.productId, + state: "Active", + billingPeriod: coerceBillingPeriod(billingPeriod), + // Play has no first-class subscription "group" — base + // plans on a single subscription product play that role, + // and we surface them as `offers[].kind === "BasePlan"` + // rows. Leave the ASC-only group fields unset. + offers: offers.length ? offers : undefined, + }); + pulled += 1; + } + token = subs.data.nextPageToken ?? undefined; + pageCount += 1; + if (pageCount > 50) break; + } while (token); + } catch (error) { + failures.push({ + productId: "(play list subscriptions)", + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + // ── PUSH: kit → Play for Draft rows ────────────────────────── + if (direction === "push" || direction === "both") { + const drafts = await ctx.runQuery( + internal.products.sync.listDraftAndroidProducts, + { projectId: project._id }, + ); + for (const row of drafts) { + try { + // When this row already has a storeRef from a prior partial + // sync, run the appropriate update endpoint instead of the + // create endpoint — Play returns 409 Conflict on + // create-with-existing-productId and ASC's parity step + // (asc.ts) does the same patch flow. Listings + price are + // both safe to re-push idempotently. Without this, kit-side + // edits made after the initial push would silently never + // reach Play (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + if (row.storeRef) { + // Track whether the patch step succeeded — only flip to + // Ready when the upstream actually accepted our changes, + // otherwise the row stays Draft and surfaces in the next + // sync's drafts list for retry (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + let patchOk = true; + if (row.type === "Subscription") { + // Subscriptions: patch the listing via + // monetization.subscriptions.patch (en-US listing only — + // multi-language sync is a future feature). Base-plan + // price changes have to go through a separate + // monetization.subscriptions.basePlans endpoint, so we + // intentionally don't try to mutate price here; that + // requires a deactivate+recreate flow Play doesn't allow + // in a single call. The dashboard surfaces a hint when + // the kit-side row has a different price than the + // pulled row so the operator knows to do that step + // manually. + try { + await androidpublisher.monetization.subscriptions.patch({ + packageName, + productId: row.storeRef, + updateMask: "listings", + // `regionsVersion` is required by the Play API on + // every patch/create — it pins the regional-pricing + // schema version (Google added the `2022/01` revision + // when they switched the regional config shape) and + // the request 400s without it. PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) + // review. The googleapis SDK exposes this as a flat + // querystring param (`regionsVersion.version`). + "regionsVersion.version": "2022/01", + requestBody: { + productId: row.storeRef, + listings: [ + { + languageCode: "en-US", + title: row.title, + description: row.description ?? row.title, + }, + ], + }, + }); + } catch (error) { + // 404 = subscription was deleted upstream after our + // last pull; surface as a failure so the operator + // re-creates it. Anything else also surfaces. + patchOk = false; + failures.push({ + productId: `${row.productId} (subscription patch)`, + reason: + error instanceof Error ? error.message : String(error), + }); + } + } else { + // One-time product: patch listings + price via the + // legacy inappproducts.patch endpoint, which accepts a + // partial body and merges it. + try { + await androidpublisher.inappproducts.patch({ + packageName, + sku: row.storeRef, + requestBody: { + packageName, + sku: row.storeRef, + purchaseType: "managedUser", + listings: { + "en-US": { + title: row.title, + description: row.description ?? row.title, + }, + }, + ...(row.priceAmountMicros !== undefined && row.currency + ? { + defaultPrice: { + priceMicros: String(row.priceAmountMicros), + currency: row.currency, + }, + } + : {}), + }, + }); + } catch (error) { + patchOk = false; + failures.push({ + productId: `${row.productId} (inapp patch)`, + reason: + error instanceof Error ? error.message : String(error), + }); + } + } + if (patchOk) { + await ctx.runMutation(internal.products.sync.markPushed, { + projectId: project._id, + productId: row.productId, + platform: "Android", + storeRef: row.storeRef, + }); + pushed += 1; + } + continue; + } + if (row.type === "Subscription") { + // Reject subscription creates that would land on Play with + // no base plan: such a subscription is created in a draft + // state that the Play app cannot purchase, which silently + // breaks the SDK's `requestPurchase` flow downstream. The + // operator must provide both a price and currency at + // minimum so we can synthesize a base plan. + if (!row.priceAmountMicros || !row.currency) { + throw new Error( + "Subscription requires priceAmountMicros + currency to mint a Play base plan; otherwise the product will not be purchasable.", + ); + } + const basePlanId = basePlanIdForPeriod(row.billingPeriod); + await androidpublisher.monetization.subscriptions.create({ + packageName, + productId: row.productId, + // `regionsVersion` is required by the v3 API on every + // create — pins the regional-pricing schema revision + // (Google introduced `2022/01` when the regional-config + // shape changed). The request 400s without it. The + // googleapis SDK exposes this as a flat querystring + // param (`regionsVersion.version`). + "regionsVersion.version": "2022/01", + requestBody: { + productId: row.productId, + listings: [ + { + languageCode: "en-US", + title: row.title, + description: row.description ?? row.title, + }, + ], + // Auto-renewing base plan. Period from the catalog row; + // defaults to monthly when the operator hasn't picked + // one. The base-plan id mirrors the duration so a row + // upgraded later from monthly→yearly doesn't collide + // with an existing base plan id in Play Console. + basePlans: [ + { + basePlanId, + autoRenewingBasePlanType: { + billingPeriodDuration: row.billingPeriod ?? "P1M", + }, + regionalConfigs: [ + { + regionCode: "US", + price: { + currencyCode: row.currency, + units: String( + Math.trunc(row.priceAmountMicros / 1_000_000), + ), + nanos: (row.priceAmountMicros % 1_000_000) * 1_000, + }, + }, + ], + }, + ], + }, + }); + // Activate the just-created base plan. Play's v3 API + // creates new base plans in DRAFT regardless of the + // `state` field on the create payload — the SKU isn't + // purchasable until `basePlans.activate` flips it to + // ACTIVE. Without this call we'd mark the row Ready while + // the upstream subscription is still non-purchasable + // (PR #124 (https://github.com/hyodotdev/openiap/pull/124) + // review). + await androidpublisher.monetization.subscriptions.basePlans.activate( + { + packageName, + productId: row.productId, + basePlanId, + requestBody: { + latencyTolerance: + "PRODUCT_UPDATE_LATENCY_TOLERANCE_LATENCY_TOLERANT", + }, + }, + ); + } else { + await androidpublisher.inappproducts.insert({ + packageName, + requestBody: { + packageName, + sku: row.productId, + // Play API uses `managedUser` for both consumable and + // non-consumable; the difference is consumed at + // purchase time via `consumeAsync`. Subscriptions go + // through `monetization.subscriptions.*` (see branch + // above), not this endpoint. + purchaseType: "managedUser", + status: "active", + defaultLanguage: "en-US", + listings: { + "en-US": { + title: row.title, + description: row.description ?? row.title, + }, + }, + ...(row.priceAmountMicros !== undefined && row.currency + ? { + defaultPrice: { + priceMicros: String(row.priceAmountMicros), + currency: row.currency, + }, + } + : {}), + }, + }); + } + // Persist storeRef immediately after the create returns, + // BEFORE flipping state to Ready via markPushed. If the + // action times out / crashes between create and markPushed, + // the next sync still sees this row's storeRef populated + // and will skip the create call (avoiding 409 Conflict + // from re-creating the same productId in Play). Mirrors the + // partial-sync resilience pattern in pushSyncProductsAppleIOS. + // Play's productId IS the storeRef (no separate opaque id). + await ctx.runMutation(internal.products.sync.markStoreRef, { + projectId: project._id, + productId: row.productId, + platform: "Android", + storeRef: row.productId, + }); + await ctx.runMutation(internal.products.sync.markPushed, { + projectId: project._id, + productId: row.productId, + platform: "Android", + storeRef: row.productId, + }); + pushed += 1; + } catch (error) { + failures.push({ + productId: row.productId, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + } + + return { pulled, pushed, failures }; + }, +}); + +function mapPlayOneTimeType( + product: androidpublisher_v3.Schema$InAppProduct, +): "Subscription" | "NonConsumable" | "Consumable" { + if (product.purchaseType === "managedUser") return "NonConsumable"; + return "Consumable"; +} + +function mapPlayStatus( + status: string | null | undefined, +): "Draft" | "Ready" | "Active" | "Removed" { + switch (status) { + case "active": + return "Active"; + case "inactive": + return "Removed"; + default: + return "Draft"; + } +} + +function pickPlayTitle( + product: androidpublisher_v3.Schema$InAppProduct, +): string | undefined { + const def = product.defaultLanguage ?? "en-US"; + return product.listings?.[def]?.title ?? undefined; +} + +function pickPlayDescription( + product: androidpublisher_v3.Schema$InAppProduct, +): string | undefined { + const def = product.defaultLanguage ?? "en-US"; + return product.listings?.[def]?.description ?? undefined; +} + +function parsePlayPriceMicros( + product: androidpublisher_v3.Schema$InAppProduct, +): number | undefined { + const raw = product.defaultPrice?.priceMicros; + if (!raw) return undefined; + const n = Number(raw); + return Number.isFinite(n) ? n : undefined; +} + +function pickPlayCurrency( + product: androidpublisher_v3.Schema$InAppProduct, +): string | undefined { + return product.defaultPrice?.currency ?? undefined; +} + +// Pick a representative price + currency for a subscription. The +// previous implementation had three bugs that combined to produce the +// "wrong currency" / "missing price" output the dashboard surfaced: +// +// 1. It bailed out (returned null price) whenever +// `legacyCompatibleSubscriptionOfferId` was set on the base plan. +// That field's presence has nothing to do with whether the plan +// has pricing — it's a migration shim from the static-pricing era +// — so any sub configured with that compat id silently lost its +// price. (Hence the second product showing "—" in the screenshot.) +// 2. It always read `regionalConfigs?.[0]`, which is just whichever +// region Google sorted first. That made the UI flip between AED / +// USD / KRW depending on the response order. +// 3. Currency and price were read independently and could disagree. +// +// New rule: walk every basePlan, walk every regionalConfig, prefer USD +// if any region offers it, otherwise return the first region with a +// readable price. Currency + price come from the SAME regionalConfig +// so they're always consistent. +function pickSubBasePlanPrice(sub: androidpublisher_v3.Schema$Subscription): { + priceAmountMicros?: number; + currency?: string; + // The basePlanId of the plan whose price we picked, so the caller + // can pull `billingPeriod` from the *same* plan instead of guessing + // (PR #124 (https://github.com/hyodotdev/openiap/pull/124) review: + // mixed monthly + yearly base plans previously paired the yearly + // USD price with the monthly billingPeriod, breaking MRR + // normalization). + basePlanId?: string; +} { + type Candidate = { + price: androidpublisher_v3.Schema$Money; + basePlanId?: string; + }; + const candidates: Candidate[] = []; + for (const plan of sub.basePlans ?? []) { + const basePlanId = plan.basePlanId ?? undefined; + for (const region of plan.regionalConfigs ?? []) { + if (region.price) candidates.push({ price: region.price, basePlanId }); + } + } + if (candidates.length === 0) return {}; + // Prefer USD when any region offers it — it's the most universally + // recognizable in a dashboard. The operator can edit per-region + // prices in Play Console; this just picks a stable display value. + const preferred = + candidates.find((c) => c.price.currencyCode === "USD") ?? candidates[0]; + return { + priceAmountMicros: moneyToMicros(preferred.price), + currency: preferred.price.currencyCode ?? undefined, + basePlanId: preferred.basePlanId, + }; +} + +// Flatten a Play subscription's basePlans + (per base plan) offers +// into kit's uniform `offers[]` shape. Each base plan becomes a +// `kind: "BasePlan"` row carrying its billing period + USD price; each +// associated subscription offer (free trial / intro discount, set up +// in Play Console) becomes a Free-Trial / IntroPay* row. Prefers USD +// regional price when present (mirrors `pickSubBasePlanPrice`'s +// rationale) so the dashboard shows a stable currency. +function collectPlaySubscriptionOffers( + sub: androidpublisher_v3.Schema$Subscription, +): Array<{ + id: string; + kind: + | "BasePlan" + | "FreeTrial" + | "IntroPayUpFront" + | "IntroPayAsYouGo" + | "PromotionalOffer"; + duration?: string; + numberOfPeriods?: number; + priceAmountMicros?: number; + currency?: string; +}> { + const out: Array<{ + id: string; + kind: + | "BasePlan" + | "FreeTrial" + | "IntroPayUpFront" + | "IntroPayAsYouGo" + | "PromotionalOffer"; + duration?: string; + numberOfPeriods?: number; + priceAmountMicros?: number; + currency?: string; + }> = []; + // Local shape for `basePlans[].offers[]` — googleapis' generated + // `Schema$BasePlan` doesn't expose offers despite the underlying + // REST resource carrying them, and we don't want to depend on the + // SDK regenerating to surface this. Mirrors the relevant fields + // from Play's `SubscriptionOffer` proto. + type PlanOfferShape = { + offerId?: string; + phases?: Array<{ + duration?: string; + recurrenceCount?: number; + regionalConfigs?: Array<{ + regionCode?: string; + price?: androidpublisher_v3.Schema$Money; + }>; + }>; + }; + type PlanWithOffers = androidpublisher_v3.Schema$BasePlan & { + offers?: PlanOfferShape[]; + }; + for (const plan of (sub.basePlans ?? []) as PlanWithOffers[]) { + if (!plan.basePlanId) continue; + const planRegions = plan.regionalConfigs ?? []; + const planPrice = + planRegions.find((r) => r.price?.currencyCode === "USD")?.price ?? + planRegions[0]?.price; + out.push({ + id: plan.basePlanId, + kind: "BasePlan", + duration: + plan.autoRenewingBasePlanType?.billingPeriodDuration ?? undefined, + priceAmountMicros: moneyToMicros(planPrice ?? undefined), + currency: planPrice?.currencyCode ?? undefined, + }); + for (const offer of plan.offers ?? []) { + if (!offer.offerId) continue; + // Walk the offer's phases. A FREE phase becomes FreeTrial; a + // DISCOUNTED phase with a single occurrence becomes + // IntroPayUpFront; multi-occurrence becomes IntroPayAsYouGo. + // Most offers only have one of these; if multiple, we emit + // multiple rows tagged with the same composite id so the + // dashboard can dedupe by basePlanId+offerId+phaseIndex. + const phases = offer.phases ?? []; + phases.forEach((phase, i) => { + const phaseRegions = phase.regionalConfigs ?? []; + const phasePrice = + phaseRegions.find((r) => r.price?.currencyCode === "USD")?.price ?? + phaseRegions[0]?.price; + // Phase with no price = free trial; with `recurrenceCount > 1` + // = pay-as-you-go intro; otherwise = pay-up-front intro. + let kind: "FreeTrial" | "IntroPayUpFront" | "IntroPayAsYouGo" = + "FreeTrial"; + const isFree = + !phasePrice || + (phasePrice.units === "0" && (phasePrice.nanos ?? 0) === 0); + if (!isFree) { + kind = + (phase.recurrenceCount ?? 1) > 1 + ? "IntroPayAsYouGo" + : "IntroPayUpFront"; + } + out.push({ + id: `${plan.basePlanId}/${offer.offerId}#${i}`, + kind, + duration: phase.duration ?? undefined, + numberOfPeriods: phase.recurrenceCount ?? undefined, + priceAmountMicros: isFree ? undefined : moneyToMicros(phasePrice), + currency: isFree + ? undefined + : (phasePrice?.currencyCode ?? undefined), + }); + }); + } + } + return out; +} + +/** + * Convert a Google `Money` proto into the integer micros (1/1,000,000 + * of the currency unit) representation kit stores on every product row. + * + * `units` is a BigInt-as-string in the Play proto, so the micros + * multiplication is done in BigInt to avoid IEEE 754 precision loss on + * large currency values (>2^53). The nanos → micros conversion is + * BigInt division which truncates (not `Math.round`, which would push + * `999_999_999` nanos up to a full 1_000_000 micros and silently add 1 + * micro to sub-unit prices). Truncation matches how Google Play Console + * stores price points internally — Play uses micros as the canonical + * unit, so any rounding here would re-introduce drift we just cleaned + * up. Resolves to `undefined` when the input has no `units`, when the + * BigInt parse throws (malformed `units` string), or when the resulting + * micros exceed `Number.MAX_SAFE_INTEGER` (≈ USD 9 billion — kit treats + * those rows as price-unknown rather than silently corrupting them). + * + * PR #124 (https://github.com/hyodotdev/openiap/pull/124) review fix. + */ +export function moneyToMicros( + money: androidpublisher_v3.Schema$Money | undefined, +): number | undefined { + if (!money?.units) return undefined; + try { + const microsBigInt = + BigInt(money.units) * 1_000_000n + BigInt(money.nanos ?? 0) / 1_000n; + // Drop values that exceed Number.MAX_SAFE_INTEGER. The schema + // stores `priceAmountMicros` as a JS `number` (IEEE 754 double), + // so anything above 2^53 - 1 would silently lose precision on + // round-trip. In practice no realistic IAP price hits that bound + // (it's ~9.0e15 micros = USD 9 billion), but for currencies with + // very high unit values like IDR / KRW it's worth the explicit + // guard rather than a silent corruption — kit treats the row as + // "price unknown" and the dashboard surfaces that affordance. + if ( + microsBigInt > BigInt(Number.MAX_SAFE_INTEGER) || + microsBigInt < BigInt(Number.MIN_SAFE_INTEGER) + ) { + return undefined; + } + return Number(microsBigInt); + } catch { + return undefined; + } +} + +/** + * Map an ISO 8601 billing-period string (`P1W` / `P1M` / `P1Y` / etc.) + * to a stable, descriptive basePlanId for the Play console. Play's + * product detail page surfaces this id verbatim, so "yearly" / + * "weekly" reads better than the default "monthly" hardcoded fallback + * we used before. Unknown / undefined periods collapse to `"monthly"`. + */ +export function basePlanIdForPeriod(period: string | undefined): string { + switch (period) { + case "P1W": + return "weekly"; + case "P2M": + return "bimonthly"; + case "P3M": + return "quarterly"; + case "P6M": + return "semiannual"; + case "P1Y": + return "yearly"; + case "P1M": + case undefined: + default: + return "monthly"; + } +} diff --git a/packages/kit/convex/products/query.ts b/packages/kit/convex/products/query.ts new file mode 100644 index 00000000..db0211e5 --- /dev/null +++ b/packages/kit/convex/products/query.ts @@ -0,0 +1,94 @@ +import { query } from "../_generated/server"; +import { v } from "convex/values"; +import type { Doc } from "../_generated/dataModel"; + +const offerShape = v.object({ + id: v.string(), + kind: v.union( + v.literal("FreeTrial"), + v.literal("IntroPayUpFront"), + v.literal("IntroPayAsYouGo"), + v.literal("PromotionalOffer"), + v.literal("BasePlan"), + ), + duration: v.optional(v.string()), + numberOfPeriods: v.optional(v.number()), + priceAmountMicros: v.optional(v.number()), + currency: v.optional(v.string()), +}); + +const productShape = v.object({ + productId: v.string(), + platform: v.union(v.literal("IOS"), v.literal("Android")), + type: v.union( + v.literal("Subscription"), + v.literal("NonConsumable"), + v.literal("Consumable"), + ), + title: v.string(), + description: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + currency: v.optional(v.string()), + state: v.union( + v.literal("Draft"), + v.literal("Ready"), + v.literal("Active"), + v.literal("Removed"), + ), + storeRef: v.optional(v.string()), + subscriptionGroupId: v.optional(v.string()), + subscriptionGroupName: v.optional(v.string()), + offers: v.optional(v.array(offerShape)), + updatedAt: v.number(), +}); + +function shape(product: Doc<"products">) { + return { + productId: product.productId, + platform: product.platform, + type: product.type, + title: product.title, + description: product.description, + priceAmountMicros: product.priceAmountMicros, + currency: product.currency, + state: product.state, + storeRef: product.storeRef, + subscriptionGroupId: product.subscriptionGroupId, + subscriptionGroupName: product.subscriptionGroupName, + offers: product.offers, + updatedAt: product.updatedAt, + }; +} + +export const listProducts = query({ + args: { + apiKey: v.string(), + platform: v.optional(v.union(v.literal("IOS"), v.literal("Android"))), + }, + returns: v.array(productShape), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + if (!project) return []; + + if (args.platform) { + const rows = await ctx.db + .query("products") + .withIndex("by_project_and_platform", (q) => + q.eq("projectId", project._id).eq("platform", args.platform!), + ) + .collect(); + return rows.map(shape); + } + + const rows = await ctx.db + .query("products") + .withIndex("by_project_and_platform_and_product", (q) => + q.eq("projectId", project._id), + ) + .collect(); + return rows.map(shape); + }, +}); diff --git a/packages/kit/convex/products/sync.ts b/packages/kit/convex/products/sync.ts new file mode 100644 index 00000000..30501863 --- /dev/null +++ b/packages/kit/convex/products/sync.ts @@ -0,0 +1,348 @@ +import { internalMutation, internalQuery } from "../_generated/server"; +import { v } from "convex/values"; +import type { Doc, Id } from "../_generated/dataModel"; + +const platformValidator = v.union(v.literal("IOS"), v.literal("Android")); +const typeValidator = v.union( + v.literal("Subscription"), + v.literal("NonConsumable"), + v.literal("Consumable"), +); +const stateValidator = v.union( + v.literal("Draft"), + v.literal("Ready"), + v.literal("Active"), + v.literal("Removed"), +); + +const offerKindValidator = v.union( + v.literal("FreeTrial"), + v.literal("IntroPayUpFront"), + v.literal("IntroPayAsYouGo"), + v.literal("PromotionalOffer"), + v.literal("BasePlan"), +); +const offerValidator = v.object({ + id: v.string(), + kind: offerKindValidator, + duration: v.optional(v.string()), + numberOfPeriods: v.optional(v.number()), + priceAmountMicros: v.optional(v.number()), + currency: v.optional(v.string()), +}); + +// Coerce a free-form billingPeriod string into the schema's literal +// union, returning undefined for unknown values. ASC and Play both +// hand us ISO-8601 strings ("P1M" / "P1Y" / etc.) but a future Apple +// enum or Play SDK quirk could leak something we don't model — in +// that case we'd rather drop the field (so MRR shows 0 with a clear +// "unknown period" log line) than persist garbage that breaks the +// schema validator. +export type BillingPeriodLiteral = + | "P1W" + | "P1M" + | "P2M" + | "P3M" + | "P6M" + | "P1Y"; +const KNOWN_BILLING_PERIODS = new Set([ + "P1W", + "P1M", + "P2M", + "P3M", + "P6M", + "P1Y", +]); +export function coerceBillingPeriod( + raw: string | undefined, +): BillingPeriodLiteral | undefined { + if (!raw) return undefined; + return KNOWN_BILLING_PERIODS.has(raw as BillingPeriodLiteral) + ? (raw as BillingPeriodLiteral) + : undefined; +} + +// Internal mutation called by the ASC / Play push-sync actions when a +// row is mirrored from the upstream store. Distinct from the public +// `upsertProduct` mutation in mutation.ts so server-driven sync can't +// be triggered by anyone holding the apiKey alone. +export const upsertFromStore = internalMutation({ + args: { + projectId: v.id("projects"), + productId: v.string(), + platform: platformValidator, + type: typeValidator, + title: v.string(), + description: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + currency: v.optional(v.string()), + storeRef: v.string(), + state: stateValidator, + // ISO-8601 billing period. Required for correct MRR + // normalization in metricsSummary — without this field synced + // subscriptions defaulted to undefined and monthlyMicrosForSub + // returned 0, silently zeroing every synced sub's contribution + // to the dashboard headline. Union mirrors the schema's + // `billingPeriod` literal — non-matching upstream values (a + // future Apple/Play enum) get coerced via mapBillingPeriodLiteral + // at the call site so this validator can stay strict. + billingPeriod: v.optional( + v.union( + v.literal("P1W"), + v.literal("P1M"), + v.literal("P2M"), + v.literal("P3M"), + v.literal("P6M"), + v.literal("P1Y"), + ), + ), + subscriptionGroupId: v.optional(v.string()), + subscriptionGroupName: v.optional(v.string()), + offers: v.optional(v.array(offerValidator)), + }, + returns: v.id("products"), + handler: async (ctx, args) => { + // Match by (projectId, platform, productId) — apps commonly use + // the same productId on both stores, and the older + // (projectId, productId)-only lookup would collide and silently + // flip an existing Android row's platform to IOS (or vice versa) + // mid-sync, deleting one platform's catalog from the dashboard's + // perspective. + const existing: Doc<"products"> | null = await ctx.db + .query("products") + .withIndex("by_project_and_platform_and_product", (q) => + q + .eq("projectId", args.projectId) + .eq("platform", args.platform) + .eq("productId", args.productId), + ) + .unique(); + const now = Date.now(); + if (existing) { + await ctx.db.patch(existing._id, { + type: args.type, + title: args.title || existing.title, + description: args.description ?? existing.description, + priceAmountMicros: args.priceAmountMicros ?? existing.priceAmountMicros, + currency: args.currency ?? existing.currency, + storeRef: args.storeRef, + state: args.state, + // Subscription metadata is sourced from the store on every + // pull, so we overwrite (not coalesce) — a sub that was + // moved between groups in ASC, or that lost a free trial in + // Play Console, should reflect that on the next sync rather + // than stick to whatever kit cached previously. Same applies + // to billingPeriod: the upstream is the source of truth. + billingPeriod: args.billingPeriod, + subscriptionGroupId: args.subscriptionGroupId, + subscriptionGroupName: args.subscriptionGroupName, + offers: args.offers, + syncedAt: now, + updatedAt: now, + }); + return existing._id; + } + const id: Id<"products"> = await ctx.db.insert("products", { + projectId: args.projectId, + productId: args.productId, + platform: args.platform, + type: args.type, + title: args.title, + description: args.description, + priceAmountMicros: args.priceAmountMicros, + currency: args.currency, + storeRef: args.storeRef, + state: args.state, + billingPeriod: args.billingPeriod, + subscriptionGroupId: args.subscriptionGroupId, + subscriptionGroupName: args.subscriptionGroupName, + offers: args.offers, + syncedAt: now, + updatedAt: now, + }); + return id; + }, +}); + +// Persist the upstream resource id immediately after the create call +// succeeds, *without* advancing state past Draft. The follow-up steps +// (localization, price schedule) may still fail, and a hard failure +// there shouldn't strand the upstream resource — the next sync needs +// to find this row, see the populated storeRef, and resume from +// step 2 instead of trying to create a duplicate. `markPushed` +// remains the success path that flips state to Ready. +export const markStoreRef = internalMutation({ + args: { + projectId: v.id("projects"), + productId: v.string(), + platform: platformValidator, + storeRef: v.string(), + }, + returns: v.union(v.id("products"), v.null()), + handler: async (ctx, args) => { + const existing = await ctx.db + .query("products") + .withIndex("by_project_and_platform_and_product", (q) => + q + .eq("projectId", args.projectId) + .eq("platform", args.platform) + .eq("productId", args.productId), + ) + .unique(); + if (!existing) return null; + await ctx.db.patch(existing._id, { + storeRef: args.storeRef, + syncedAt: Date.now(), + updatedAt: Date.now(), + }); + return existing._id; + }, +}); + +// After a successful push, write the upstream resource id back so the +// next pull doesn't double-create. +export const markPushed = internalMutation({ + args: { + projectId: v.id("projects"), + productId: v.string(), + platform: platformValidator, + storeRef: v.string(), + }, + returns: v.union(v.id("products"), v.null()), + handler: async (ctx, args) => { + const existing = await ctx.db + .query("products") + .withIndex("by_project_and_platform_and_product", (q) => + q + .eq("projectId", args.projectId) + .eq("platform", args.platform) + .eq("productId", args.productId), + ) + .unique(); + if (!existing) return null; + await ctx.db.patch(existing._id, { + storeRef: args.storeRef, + state: "Ready", + syncedAt: Date.now(), + updatedAt: Date.now(), + }); + return existing._id; + }, +}); + +// Pull every Draft iOS row that the push pass should attempt. We do +// NOT gate on `storeRef === undefined` here: a previous sync may have +// successfully created the upstream resource (storeRef now populated) +// but failed on a subsequent step (localization / price schedule). +// Such rows stay in state=Draft and the push branch needs to revisit +// them — using their existing storeRef to skip the create call and +// retry only the failed steps. The push branch handles the +// "skip create when storeRef already set" decision. +export const listDraftIosProducts = internalQuery({ + args: { projectId: v.id("projects") }, + returns: v.array( + v.object({ + productId: v.string(), + platform: platformValidator, + type: typeValidator, + title: v.string(), + description: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + currency: v.optional(v.string()), + billingPeriod: v.optional( + v.union( + v.literal("P1W"), + v.literal("P1M"), + v.literal("P2M"), + v.literal("P3M"), + v.literal("P6M"), + v.literal("P1Y"), + ), + ), + subscriptionGroupName: v.optional(v.string()), + reviewNote: v.optional(v.string()), + storeRef: v.optional(v.string()), + }), + ), + handler: async (ctx, args) => { + const all = await ctx.db + .query("products") + .withIndex("by_project_and_platform", (q) => + q.eq("projectId", args.projectId).eq("platform", "IOS"), + ) + .collect(); + return all + .filter((row) => row.state === "Draft") + .map((row) => ({ + productId: row.productId, + platform: row.platform, + type: row.type, + title: row.title, + description: row.description, + priceAmountMicros: row.priceAmountMicros, + currency: row.currency, + billingPeriod: row.billingPeriod, + subscriptionGroupName: row.subscriptionGroupName, + reviewNote: row.reviewNote, + storeRef: row.storeRef, + })); + }, +}); + +// Same for Android — used by the Play push action. +export const listDraftAndroidProducts = internalQuery({ + args: { projectId: v.id("projects") }, + returns: v.array( + v.object({ + productId: v.string(), + platform: platformValidator, + type: typeValidator, + title: v.string(), + description: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + currency: v.optional(v.string()), + billingPeriod: v.optional( + v.union( + v.literal("P1W"), + v.literal("P1M"), + v.literal("P2M"), + v.literal("P3M"), + v.literal("P6M"), + v.literal("P1Y"), + ), + ), + storeRef: v.optional(v.string()), + }), + ), + handler: async (ctx, args) => { + const all = await ctx.db + .query("products") + .withIndex("by_project_and_platform", (q) => + q.eq("projectId", args.projectId).eq("platform", "Android"), + ) + .collect(); + // Mirror the iOS filter: state === Draft only. The earlier + // `storeRef === undefined` guard was added to avoid re-pushing + // Pull-imported rows that already existed upstream, but it also + // blocked partial-sync resumption — a Draft row whose create + // succeeded but whose listing/price step failed never got + // retried. play.ts now branches on `row.storeRef` at the top of + // the push loop and PATCHes existing storeRefs instead of + // creating, so both the partial-sync and pull-then-push cases + // are correct without the extra filter (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + return all + .filter((row) => row.state === "Draft") + .map((row) => ({ + productId: row.productId, + platform: row.platform, + type: row.type, + title: row.title, + description: row.description, + priceAmountMicros: row.priceAmountMicros, + currency: row.currency, + billingPeriod: row.billingPeriod, + storeRef: row.storeRef, + })); + }, +}); diff --git a/packages/kit/convex/projects/mutation.ts b/packages/kit/convex/projects/mutation.ts index ef25b561..7b603dc4 100644 --- a/packages/kit/convex/projects/mutation.ts +++ b/packages/kit/convex/projects/mutation.ts @@ -260,6 +260,11 @@ export const updateProject = mutation({ iosAppAppleId: v.optional(v.number()), iosAppStoreIssuerId: v.optional(v.string()), iosAppStoreKeyId: v.optional(v.string()), + // App Store Connect API credentials — separate from the Server API + // (In-App Purchase) credentials above. See schema.ts for the + // distinction. Used by `products/asc.ts` push-sync. + iosAscIssuerId: v.optional(v.string()), + iosAscKeyId: v.optional(v.string()), // Meta Horizon (Quest / VR) — piggybacks on the Android section // in the dashboard since the client SDK is Google-Play-Billing- // compatible. Validation only runs when horizonEnabled === true. @@ -319,6 +324,12 @@ export const updateProject = mutation({ if (args.iosAppStoreKeyId !== undefined) { updates.iosAppStoreKeyId = normalizeAppStoreKeyId(args.iosAppStoreKeyId); } + if (args.iosAscIssuerId !== undefined) { + updates.iosAscIssuerId = normalizeAppStoreIssuerId(args.iosAscIssuerId); + } + if (args.iosAscKeyId !== undefined) { + updates.iosAscKeyId = normalizeAppStoreKeyId(args.iosAscKeyId); + } // Horizon fields: validated only when the feature is being // enabled or when populated values are supplied. Toggling off diff --git a/packages/kit/convex/projects/setupStatus.ts b/packages/kit/convex/projects/setupStatus.ts new file mode 100644 index 00000000..08a663ab --- /dev/null +++ b/packages/kit/convex/projects/setupStatus.ts @@ -0,0 +1,99 @@ +import { query } from "../_generated/server"; +import { v } from "convex/values"; + +// Public query — surfaces which platforms a project has configured so +// the dashboard, the SDK, and the MCP server can return a precise +// "X not configured" error instead of a silent empty response. +// +// Auth via apiKey (same model as the rest of the v1 surface). Returns +// `found: false` when the key is unknown so the dashboard can render +// "log in to a different project" without leaking which keys exist. + +const platformShape = v.object({ + configured: v.boolean(), + missing: v.array(v.string()), +}); + +export const getSetupStatus = query({ + args: { apiKey: v.string() }, + returns: v.object({ + found: v.boolean(), + projectId: v.union(v.id("projects"), v.null()), + ios: platformShape, + android: platformShape, + horizon: platformShape, + appleP8Uploaded: v.boolean(), + googleServiceAccountUploaded: v.boolean(), + }), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + + if (!project) { + const empty = { configured: false, missing: ["project not found"] }; + return { + found: false, + projectId: null, + ios: empty, + android: empty, + horizon: empty, + appleP8Uploaded: false, + googleServiceAccountUploaded: false, + }; + } + + // Pull the project's uploaded files once so we can both report + // field-level config AND surface .p8 / service-account presence + // in the same response — the dashboard's setup card was always + // rendering "missing" because the previous shape hardcoded both + // flags to false. + const projectFiles = await ctx.db + .query("files") + .withIndex("by_project", (q) => q.eq("projectId", project._id)) + .collect(); + + const iosMissing: string[] = []; + if (!project.iosBundleId) iosMissing.push("iosBundleId"); + if (!project.iosAppAppleId) iosMissing.push("iosAppAppleId"); + if (!project.iosAppStoreIssuerId) iosMissing.push("iosAppStoreIssuerId"); + if (!project.iosAppStoreKeyId) iosMissing.push("iosAppStoreKeyId"); + + const androidMissing: string[] = []; + if (!project.androidPackageName) androidMissing.push("androidPackageName"); + + const horizonMissing: string[] = []; + if (!project.horizonEnabled) horizonMissing.push("horizonEnabled"); + if (!project.horizonAppId) horizonMissing.push("horizonAppId"); + if (!project.horizonAppSecret) horizonMissing.push("horizonAppSecret"); + + return { + found: true, + projectId: project._id, + ios: { + configured: iosMissing.length === 0, + missing: iosMissing, + }, + android: { + configured: androidMissing.length === 0, + missing: androidMissing, + }, + horizon: { + configured: horizonMissing.length === 0, + missing: horizonMissing, + }, + // The webhook receivers ALSO need the .p8 / service-account JSON + // file uploaded to the project; check the `files` table directly + // so the setup card reflects what the operator has actually + // uploaded instead of always reporting "missing". + appleP8Uploaded: projectFiles.some( + (f) => + f.purpose === "apple_p8_key" || f.purpose === "apple_p8_asc_api_key", + ), + googleServiceAccountUploaded: projectFiles.some( + (f) => f.purpose === "android_service_account", + ), + }; + }, +}); diff --git a/packages/kit/convex/purchases/cleanup.test.ts b/packages/kit/convex/purchases/cleanup.test.ts index 78fad446..68dcfea8 100644 --- a/packages/kit/convex/purchases/cleanup.test.ts +++ b/packages/kit/convex/purchases/cleanup.test.ts @@ -132,7 +132,7 @@ class MemDb { slug: "test-project", createdAt: Date.now(), updatedAt: Date.now(), - } as Row); + }); return id; } @@ -154,7 +154,7 @@ class MemDb { orderId: attrs.orderId, isValid: attrs.isValid ?? true, state: "ENTITLED", - } as Row); + }); } allPurchases(): Row[] { diff --git a/packages/kit/convex/purchases/save-purchase-idempotency.test.ts b/packages/kit/convex/purchases/save-purchase-idempotency.test.ts index 9e7f226f..81f63308 100644 --- a/packages/kit/convex/purchases/save-purchase-idempotency.test.ts +++ b/packages/kit/convex/purchases/save-purchase-idempotency.test.ts @@ -157,7 +157,7 @@ class MemDb { createdAt: Date.now(), updatedAt: Date.now(), ...doc, - } as Row); + }); return id; } @@ -170,7 +170,7 @@ class MemDb { slug: "test-project", createdAt: Date.now(), updatedAt: Date.now(), - } as Row); + }); return id; } @@ -253,10 +253,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { it("re-validation with the same remoteId does NOT re-increment stats.total", async () => { await savePurchaseInternal({ ctx, ...buildArgs({ remoteId: TOKEN }) }); - const afterFirst = await readPurchaseStats( - ctx as never, - PROJECT_ID as never, - ); + const afterFirst = await readPurchaseStats(ctx, PROJECT_ID as never); expect(afterFirst.total).toBe(1); expect(afterFirst.google).toBe(1); @@ -264,10 +261,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { await savePurchaseInternal({ ctx, ...buildArgs({ remoteId: TOKEN }) }); await savePurchaseInternal({ ctx, ...buildArgs({ remoteId: TOKEN }) }); - const afterRepeat = await readPurchaseStats( - ctx as never, - PROJECT_ID as never, - ); + const afterRepeat = await readPurchaseStats(ctx, PROJECT_ID as never); expect(afterRepeat.total).toBe(1); expect(afterRepeat.google).toBe(1); }); @@ -294,7 +288,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { expect(db.purchaseCount()).toBe(1); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); // Total stays at 1. Valid moves 1 → 0, invalid moves 0 → 1. expect(stats.total).toBe(1); expect(stats.valid).toBe(0); @@ -312,7 +306,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { }); expect(db.purchaseCount()).toBe(2); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats.total).toBe(2); expect(stats.google).toBe(2); }); @@ -361,7 +355,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { expect(rows[0]?.remoteId).toBe("token_after_reissue"); expect(rows[0]?.orderId).toBe("GPA.3328-5001-2345-67890"); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats.total).toBe(1); expect(stats.google).toBe(1); // Distinct Play Console orders: exactly one, because the two @@ -392,7 +386,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { }); expect(db.purchaseCount()).toBe(2); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats.total).toBe(2); expect(stats.google).toBe(2); expect(stats.googleOrders).toBe(2); @@ -416,7 +410,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { expect(db.purchaseCount()).toBe(2); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); // Two rows but no distinct orders yet — googleOrders stays at 0 // until a later re-verify surfaces an orderId. expect(stats.google).toBe(2); @@ -436,10 +430,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { }), }); - const beforeAck = await readPurchaseStats( - ctx as never, - PROJECT_ID as never, - ); + const beforeAck = await readPurchaseStats(ctx, PROJECT_ID as never); expect(beforeAck.googleOrders).toBe(0); await savePurchaseInternal({ @@ -457,7 +448,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { }); expect(db.purchaseCount()).toBe(1); - const afterAck = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const afterAck = await readPurchaseStats(ctx, PROJECT_ID as never); expect(afterAck.google).toBe(1); expect(afterAck.googleOrders).toBe(1); expect(afterAck.total).toBe(1); @@ -514,10 +505,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { ...buildArgs({ remoteId: TOKEN, remoteResponse: ackResponse }), }); - const beforeError = await readPurchaseStats( - ctx as never, - PROJECT_ID as never, - ); + const beforeError = await readPurchaseStats(ctx, PROJECT_ID as never); expect(beforeError.googleOrders).toBe(1); // A later re-verify gets a transient Google failure — we persist @@ -539,10 +527,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { const rows = await db.query("purchases").collect(); expect(rows[0]?.orderId).toBe("GPA.order-stable"); - const afterError = await readPurchaseStats( - ctx as never, - PROJECT_ID as never, - ); + const afterError = await readPurchaseStats(ctx, PROJECT_ID as never); // googleOrders must stay at 1: the distinct orderId on this row // is still present in the database, just not in the latest // response payload. @@ -593,7 +578,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { expect(rows[0]?.remoteId).toBe("token_initial"); expect(rows[0]?.orderId).toBe("GPA.only-one-logical-order"); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats.google).toBe(1); expect(stats.googleOrders).toBe(1); expect(stats.total).toBe(1); @@ -657,7 +642,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { expect(rows[0]?.state).toBe(HarmonizedPurchaseState.ENTITLED); expect(rows[0]?.isValid).toBe(true); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats.total).toBe(1); expect(stats.valid).toBe(1); expect(stats.invalid).toBe(0); diff --git a/packages/kit/convex/purchases/stats-integration.test.ts b/packages/kit/convex/purchases/stats-integration.test.ts index 5cc319d5..f21e2d76 100644 --- a/packages/kit/convex/purchases/stats-integration.test.ts +++ b/packages/kit/convex/purchases/stats-integration.test.ts @@ -138,7 +138,7 @@ describe("stats helpers — round-trip integration", () => { }); it("readPurchaseStats returns zeros when no row exists yet", async () => { - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats).toEqual({ total: 0, apple: 0, @@ -151,12 +151,12 @@ describe("stats helpers — round-trip integration", () => { it("applyPurchaseStatsDelta creates a row on first insert-delta", async () => { await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", true), ); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats).toEqual({ total: 1, apple: 1, @@ -169,29 +169,29 @@ describe("stats helpers — round-trip integration", () => { it("accumulates correctly across multiple insert-deltas for mixed stores/validity", async () => { await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", true), ); await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, // pending-ack google insert — row count but no order yet deltaForInsert("google", true), ); await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, // invalid google with orderId — order but marked invalid deltaForInsert("google", false, true), ); await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", false), ); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats).toEqual({ total: 4, apple: 2, @@ -206,7 +206,7 @@ describe("stats helpers — round-trip integration", () => { it("markReceiptInvalid-style flip preserves total and moves valid -> invalid", async () => { // Seed: one apple valid receipt. await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", true), ); @@ -215,12 +215,12 @@ describe("stats helpers — round-trip integration", () => { // Apple receipts don't carry a Google orderId, so the last two // args are false/false. await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForUpdate("apple", true, "apple", false, false, false), ); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats).toEqual({ total: 1, apple: 1, @@ -234,7 +234,7 @@ describe("stats helpers — round-trip integration", () => { describe("wasFirstValidTransition", () => { it("is true on the insert that bumps valid from 0 to 1", async () => { const result = await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", true), ); @@ -243,12 +243,12 @@ describe("stats helpers — round-trip integration", () => { it("is false on a second valid insert (valid was already 1)", async () => { await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", true), ); const second = await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", true), ); @@ -257,7 +257,7 @@ describe("stats helpers — round-trip integration", () => { it("is false on an invalid insert (valid stayed at 0)", async () => { const result = await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", false), ); @@ -267,13 +267,13 @@ describe("stats helpers — round-trip integration", () => { it("is true when a retry flips an existing row from invalid to valid (0 → 1)", async () => { // Seed: invalid row → valid:0, invalid:1 await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("google", false), ); // Retry succeeds — deltaForUpdate moves valid 0 → 1 const flip = await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForUpdate("google", false, "google", true), ); @@ -282,12 +282,12 @@ describe("stats helpers — round-trip integration", () => { it("is false when a valid row is flipped to invalid (1 → 0, not an activation)", async () => { await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", true), ); const flip = await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForUpdate("apple", true, "apple", false), ); @@ -296,7 +296,7 @@ describe("stats helpers — round-trip integration", () => { it("is false on a no-op delta (early-return branch)", async () => { const result = await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, {}, ); @@ -306,20 +306,20 @@ describe("stats helpers — round-trip integration", () => { it("remoteId upsert with unchanged (store, isValid) emits no counter movement", async () => { await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("google", true), ); - const before = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const before = await readPurchaseStats(ctx, PROJECT_ID as never); await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForUpdate("google", true, "google", true), ); - const after = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const after = await readPurchaseStats(ctx, PROJECT_ID as never); expect(after).toEqual(before); }); @@ -327,12 +327,12 @@ describe("stats helpers — round-trip integration", () => { // No insert — now simulate a rogue 'valid -> invalid' flip on nothing. // Counters should not dip below zero. await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForUpdate("apple", true, "apple", false), ); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats.valid).toBe(0); expect(stats.invalid).toBe(1); expect(stats.total).toBeGreaterThanOrEqual(0); @@ -340,14 +340,14 @@ describe("stats helpers — round-trip integration", () => { it("deletePurchaseStatsForProject removes the stats row", async () => { await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", true), ); - await deletePurchaseStatsForProject(ctx as never, PROJECT_ID as never); + await deletePurchaseStatsForProject(ctx, PROJECT_ID as never); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats).toEqual({ total: 0, apple: 0, @@ -415,7 +415,7 @@ describe("stats helpers — round-trip integration", () => { }); const totals = await recomputePurchaseStatsForProject( - ctx as never, + ctx, PROJECT_ID as never, ); expect(totals).toEqual({ @@ -430,7 +430,7 @@ describe("stats helpers — round-trip integration", () => { }); // Persisted to the stats table so subsequent reads are O(1). - const read = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const read = await readPurchaseStats(ctx, PROJECT_ID as never); expect(read).toEqual(totals); }); @@ -450,11 +450,11 @@ describe("stats helpers — round-trip integration", () => { }); const first = await recomputePurchaseStatsForProject( - ctx as never, + ctx, PROJECT_ID as never, ); const second = await recomputePurchaseStatsForProject( - ctx as never, + ctx, PROJECT_ID as never, ); expect(second).toEqual(first); diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts index 5bc85fbe..3a42ba44 100644 --- a/packages/kit/convex/schema.ts +++ b/packages/kit/convex/schema.ts @@ -185,8 +185,22 @@ const schema = defineSchema({ androidPackageName: v.optional(v.string()), iosBundleId: v.optional(v.string()), iosAppAppleId: v.optional(v.number()), + // App Store Server API credentials — issued under "Users and + // Access → Integrations → In-App Purchase". Used by the receipt + // verifier in `purchases/ios.ts`. Pairs with the `.p8` file + // stored as `purpose: "apple_p8_key"`. iosAppStoreIssuerId: v.optional(v.string()), iosAppStoreKeyId: v.optional(v.string()), + // App Store Connect API credentials — issued under "Users and + // Access → Integrations → App Store Connect API → Team Keys" + // (or Individual Keys). Used by `products/asc.ts` push-sync. + // Genuinely a different key from the App Store Server API one; + // Apple scopes them separately at the gateway. Pairs with the + // `.p8` file stored as `purpose: "apple_p8_asc_api_key"`. Both + // are optional so existing iOS-only-receipt-verification + // projects keep working without push-sync. + iosAscIssuerId: v.optional(v.string()), + iosAscKeyId: v.optional(v.string()), // Meta Horizon Billing (Quest / Meta VR). Piggybacks on the Android // configuration card in the UI because the client SDK is @@ -210,7 +224,11 @@ const schema = defineSchema({ }) .index("by_organization", ["organizationId"]) .index("by_api_key", ["apiKey"]) - .index("by_org_and_slug", ["organizationId", "slug"]), + .index("by_org_and_slug", ["organizationId", "slug"]) + // Horizon polling reconciler iterates only the projects that + // opted into Meta Horizon billing — without this index the cron + // would full-scan every project on each tick. + .index("by_horizon_enabled", ["horizonEnabled"]), // API Keys table - Multiple API keys per project apiKeys: defineTable({ @@ -255,10 +273,18 @@ const schema = defineSchema({ fileType: v.string(), // MIME type fileSize: v.number(), // Size in bytes - // Purpose/category + // Purpose/category. Apple distributes two distinct .p8 key kinds + // and they're NOT interchangeable: + // - `apple_p8_key` — App Store Server API (the + // "In-App Purchase Key"). Used for receipt verification. + // - `apple_p8_asc_api_key` — App Store Connect API (the "Team + // Key" / "Individual Key"). Used for ASC REST endpoints + // (catalog list / create / patch). Push-sync calls these. + // Uploading the wrong kind for either purpose returns 401. purpose: v.union( - v.literal("apple_p8_key"), // Apple .p8 private key - v.literal("android_service_account"), // Android Service Account + v.literal("apple_p8_key"), + v.literal("apple_p8_asc_api_key"), + v.literal("android_service_account"), ), description: v.optional(v.string()), @@ -401,6 +427,417 @@ const schema = defineSchema({ cursor: v.number(), updatedAt: v.number(), }).index("by_jobName", ["jobName"]), + + // Normalized lifecycle webhook events ingested from Apple ASN v2 and + // Google RTDN. Mirrors the GraphQL `WebhookEvent` shape defined in + // `packages/gql/src/webhook.graphql` — kit's Subscription endpoint + // streams rows from this table to authenticated clients, and the + // `webhookEventsSince` query backfills events that occurred while a + // client's WebSocket was closed. + // + // Retention: rows are pruned by the `pruneWebhookEvents` cron after + // 30 days. The replay window matches `webhookEventsSince` so clients + // returning from a long offline period can still reconcile. + webhookEvents: defineTable({ + projectId: v.id("projects"), + type: v.union( + v.literal("SubscriptionStarted"), + v.literal("SubscriptionRenewed"), + v.literal("SubscriptionExpired"), + v.literal("SubscriptionInGracePeriod"), + v.literal("SubscriptionInBillingRetry"), + v.literal("SubscriptionRecovered"), + v.literal("SubscriptionCanceled"), + v.literal("SubscriptionUncanceled"), + v.literal("SubscriptionRevoked"), + v.literal("SubscriptionPriceChange"), + v.literal("SubscriptionProductChanged"), + v.literal("SubscriptionPaused"), + v.literal("SubscriptionResumed"), + v.literal("PurchaseRefunded"), + v.literal("PurchaseConsumptionRequest"), + v.literal("TestNotification"), + ), + source: v.union( + v.literal("AppleAppStoreServerNotificationsV2"), + v.literal("GooglePlayRealTimeDeveloperNotifications"), + // Synthetic source for Meta Horizon Store entitlement + // transitions discovered by the polling reconciler. Mirrors + // the GraphQL `WebhookEventSource.MetaHorizonReconciler` enum. + v.literal("MetaHorizonReconciler"), + ), + platform: v.union(v.literal("IOS"), v.literal("Android")), + environment: v.union( + v.literal("Production"), + v.literal("Sandbox"), + v.literal("Xcode"), + ), + // Optional because TestNotification payloads (App Store Connect + // "Send Test Notification" / RTDN setup test) carry no transaction + // and therefore no purchaseToken. All real lifecycle event types + // populate this; the receiver guards apply the same nullability + // (see webhooks/internal.ts). + purchaseToken: v.optional(v.string()), + // Original notification id from the store (ASN v2 `notificationUUID` + // or RTDN Pub/Sub `messageId`). Surfaced as the GraphQL `id` field + // for clients and used to correlate events during pruning. + sourceNotificationId: v.string(), + productId: v.optional(v.string()), + subscriptionState: v.optional( + v.union( + v.literal("Active"), + v.literal("InGracePeriod"), + v.literal("InBillingRetry"), + v.literal("Expired"), + v.literal("Revoked"), + v.literal("Refunded"), + v.literal("Paused"), + v.literal("Unknown"), + ), + ), + expiresAt: v.optional(v.number()), + renewsAt: v.optional(v.number()), + cancellationReason: v.optional( + v.union( + v.literal("UserCanceled"), + v.literal("BillingError"), + v.literal("PriceIncreaseDeclined"), + v.literal("ProductUnavailable"), + v.literal("Refunded"), + v.literal("Other"), + ), + ), + currency: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + rawSignedPayload: v.optional(v.string()), + occurredAt: v.number(), + receivedAt: v.number(), + }) + .index("by_project", ["projectId"]) + .index("by_purchase_token", ["purchaseToken"]) + .index("by_project_and_received", ["projectId", "receivedAt"]) + .index("by_received_at", ["receivedAt"]) + // Lookup helper used by the SSE stream's `Last-Event-ID` cursor + // resolution. The reconnect cursor needs to translate a stable + // notification id back to its `receivedAt` regardless of whether + // the event is in the first 500 or the 50,000th. A direct index + // hit is O(log n) vs O(n/page) for the prior linear scan. + .index("by_project_and_notification_id", [ + "projectId", + "sourceNotificationId", + ]) + // Composite (projectId, receivedAt, _creationTime) for the SSE + // backfill `webhookEventsSince` query — lets the boundary-cohort + // tail past the millisecond cursor be walked directly via the + // index (`gt("_creationTime", afterCreationTime)`) instead of an + // in-memory filter that would silently drop pages when a single + // millisecond's burst exceeds the take() cap (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + .index("by_project_and_received_and_creation", [ + "projectId", + "receivedAt", + "_creationTime", + ]), + + // Dedup table for webhook payloads. Insertion uses + // `(projectId, source, sourceNotificationId)` as the natural key. + // projectId is part of the key because Google Cloud Pub/Sub's + // messageId is only guaranteed unique *within a topic* — different + // kit projects can legitimately publish notifications with the + // same messageId, and a project-less key would cross-pollute their + // dedup state. (Apple's notificationUUID is globally unique so the + // projectId scope is redundant for ASN, but matching one shape + // keeps the lookup path simple.) Duplicates detected here cause + // kit to silently ACK the upstream request with 200 without + // re-emitting the event, matching Apple's documented retry + // expectation and Google's at-least-once Pub/Sub contract. + // `projectId` is optional during the rollout so already-written + // rows still validate; new inserts always populate it. + webhookIdempotencyKeys: defineTable({ + projectId: v.optional(v.id("projects")), + source: v.union(v.literal("apple"), v.literal("google")), + sourceNotificationId: v.string(), + eventId: v.optional(v.id("webhookEvents")), + firstSeenAt: v.number(), + }) + .index("by_source_and_id", ["source", "sourceNotificationId"]) + .index("by_project_and_source_and_id", [ + "projectId", + "source", + "sourceNotificationId", + ]) + // Cheap range scan for the `pruneWebhookEvents` cron — without it, + // ageing out dedup rows means a full-table scan per tick, which + // gets expensive on a hosted multi-tenant deployment where the + // table grows ~1 row per webhook per project per day. + .index("by_first_seen_at", ["firstSeenAt"]), + + // Authoritative per-(project, originalTransactionId) subscription record. + // Mirrors the spec from `packages/gql/src/webhook.graphql` and the role + // played by onesub's `onesub_subscriptions` table. State transitions are + // driven by webhook events through `applySubscriptionEvent`. + // + // Why per-`originalTransactionId` (Apple) / `purchaseToken` (Google) and + // not per-`(userId, productId)`: a single user can hold multiple historical + // entitlements (resub after expiry, cross-grade, family-shared); the + // store-issued purchase id is the only stable handle that survives all + // transitions. Entitlement evaluation aggregates by user as needed. + // + // Known limitation (Google `linkedPurchaseToken` chain): Google reissues + // `purchaseToken` across upgrade/downgrade/replace flows. The new token + // arrives via RTDN with no `linkedPurchaseToken` field in the webhook + // payload itself — that field is only available via a follow-up + // `purchases.subscriptionsv2.get` Play Developer API call. The webhook + // receiver intentionally does NOT make that synchronous call (it would + // violate Pub/Sub's fast-ACK contract and burn Play API quota per + // webhook). The result is one logical Google subscription can split + // into multiple rows after a token reissue, fragmenting the per-token + // state until a background reconciliation pass resolves the chain + // via the Play API and merges the rows. + // + // Apple does not have this problem — `originalTransactionId` is stable + // across the entire entitlement lifetime. + subscriptions: defineTable({ + projectId: v.id("projects"), + purchaseToken: v.string(), + userId: v.optional(v.string()), + productId: v.string(), + platform: v.union(v.literal("IOS"), v.literal("Android")), + state: v.union( + v.literal("Active"), + v.literal("InGracePeriod"), + v.literal("InBillingRetry"), + v.literal("Expired"), + v.literal("Revoked"), + v.literal("Refunded"), + v.literal("Paused"), + v.literal("Unknown"), + ), + expiresAt: v.optional(v.number()), + renewsAt: v.optional(v.number()), + willRenew: v.optional(v.boolean()), + cancellationReason: v.optional( + v.union( + v.literal("UserCanceled"), + v.literal("BillingError"), + v.literal("PriceIncreaseDeclined"), + v.literal("ProductUnavailable"), + v.literal("Refunded"), + v.literal("Other"), + ), + ), + currency: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + startedAt: v.number(), + updatedAt: v.number(), + lastEventId: v.optional(v.id("webhookEvents")), + }) + .index("by_project_and_token", ["projectId", "purchaseToken"]) + .index("by_project_and_user", ["projectId", "userId"]) + .index("by_project_and_state", ["projectId", "state"]) + .index("by_project_and_updated", ["projectId", "updatedAt"]) + .index("by_project_and_product", ["projectId", "productId"]) + // Composite index for the (state + productId) filter combination + // in listSubscriptions. Without it, the prior over-fetch heuristic + // could miss matching rows past the take() boundary on projects + // with thousands of subs in the same state. + .index("by_project_and_state_and_product", [ + "projectId", + "state", + "productId", + ]) + // Composite (projectId, state, updatedAt) for the Horizon + // reconciler's per-state, oldest-first pagination. With this index + // we walk the staleest subs in each mutable state per cron tick; + // after Meta verify_entitlement writes the fresh `updatedAt`, the + // row moves to the back of the queue automatically. That makes + // the reconciler self-paginating across ticks — no separate + // continuation cursor needed (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + .index("by_project_and_state_and_updated", [ + "projectId", + "state", + "updatedAt", + ]), + + // Incrementally-maintained per-(project, currency) subscription + // counters + MRR. Updated by `applySubscriptionEvent` so the + // dashboard's `metricsSummary` query reads O(currencies) rows + // instead of scanning the whole `subscriptions` table — the prior + // implementation capped at 10,000 subs to bound Convex's read + // budget, which silently undercounted projects above that + // threshold. + // + // Keyed by currency because MRR can't be summed across + // currencies without a presentation-layer FX conversion (matches + // the same reasoning on `revenueMetricsDaily`). + // + // 30-day rolling counters (refunded, canceled) are NOT stored + // here — those are bounded-size by definition (limited by 30 days + // of churn, not by total subs) so the read path scans them via + // `by_project_and_state` filtered on `updatedAt >= cutoff`. + subscriptionStats: defineTable({ + projectId: v.id("projects"), + currency: v.string(), + activeSubs: v.number(), + inGracePeriod: v.number(), + inBillingRetry: v.number(), + mrrMicros: v.number(), + updatedAt: v.number(), + }) + .index("by_project", ["projectId"]) + .index("by_project_and_currency", ["projectId", "currency"]) + // Ordered scan for `recomputeAllSubscriptionStats` cron picker — + // walks the most-stale rows first via .order("asc").take(limit) + // so we never collect the whole table to sort it client-side. + .index("by_updated_at", ["updatedAt"]), + + // Daily revenue metrics rollup keyed by (projectId, day, productId, + // currency). Populated by `recomputeRevenueMetrics` cron (recomputes + // the trailing window from `subscriptions` so late-arriving webhook + // corrections are reflected). The dashboard reads from here to avoid + // scanning the full events log on every page render. + // + // Currency is part of the row key because the same SKU can sell in + // multiple storefront currencies on the same UTC day — keying only + // by (projectId, day, productId) would either mix incompatible + // `revenueMicros` totals or have one currency overwrite another, + // both of which produce wrong dashboard numbers for multi-region + // apps. Aggregating across currencies is a presentation-layer + // concern (FX conversion happens in the UI, with whatever rates the + // operator picks). + revenueMetricsDaily: defineTable({ + projectId: v.id("projects"), + day: v.string(), // ISO date (YYYY-MM-DD), UTC + productId: v.string(), + currency: v.string(), + activeSubs: v.number(), + newSubs: v.number(), + renewals: v.number(), + cancellations: v.number(), + refunds: v.number(), + revenueMicros: v.number(), + updatedAt: v.number(), + }) + .index("by_project_and_day_and_currency", ["projectId", "day", "currency"]) + .index("by_project_and_product_and_day_and_currency", [ + "projectId", + "productId", + "day", + "currency", + ]), + + // Unified product catalog. Mirrors what onesub holds in @onesub/providers + // — the subset of App Store Connect / Play Console that kit can read / + // create / update on the project owner's behalf. The auth-credential + // payloads themselves stay in `files` (existing kit pattern); this row is + // just the cached product metadata. + products: defineTable({ + projectId: v.id("projects"), + productId: v.string(), + platform: v.union(v.literal("IOS"), v.literal("Android")), + type: v.union( + v.literal("Subscription"), + v.literal("NonConsumable"), + v.literal("Consumable"), + ), + title: v.string(), + description: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + currency: v.optional(v.string()), + state: v.union( + v.literal("Draft"), + v.literal("Ready"), + v.literal("Active"), + v.literal("Removed"), + ), + // Subscription billing period. ISO-8601-ish duration the ASC + Play + // push paths both accept (`P1W` / `P1M` / `P2M` / `P3M` / `P6M` / + // `P1Y`). Optional because non-subscription types don't use it. + // The push actions translate this to ASC `subscriptionPeriod` enum + // (`ONE_WEEK` / `ONE_MONTH` / `TWO_MONTHS` / …) and Play + // `autoRenewingBasePlanType.billingPeriodDuration`. Without this + // field, the prior implementation silently created every + // subscription as ONE_MONTH / P1M regardless of intent. + billingPeriod: v.optional( + v.union( + v.literal("P1W"), + v.literal("P1M"), + v.literal("P2M"), + v.literal("P3M"), + v.literal("P6M"), + v.literal("P1Y"), + ), + ), + // Subscription Group (ASC concept; Play has no first-class + // equivalent so these stay null for Android rows). All + // subscriptions in the same group are mutually exclusive on + // Apple's side — the user can switch between Premium / Premium + // Year via in-app upgrade/downgrade, but cannot hold both. Kit + // surfaces this in the dashboard so the operator can see at a + // glance which subs share a group, and downstream surfaces can + // pick a default selection within a group. `subscriptionGroupId` + // is Apple's internal resource id; `subscriptionGroupName` is the + // human-readable referenceName the operator sees in ASC. + subscriptionGroupId: v.optional(v.string()), + subscriptionGroupName: v.optional(v.string()), + // Captures the *non-base* monetization variants attached to a + // subscription: Apple introductory offers (free trial / pay as + // you go / pay up front) and Play base plan offers (same kinds, + // different shape). Stored as a generic shape so both stores can + // upsert without branching, and the dashboard can render badges + // ("7-day free trial", "$4.99 intro for 3 months") without + // re-deriving from raw store responses. + offers: v.optional( + v.array( + v.object({ + // Identifier from the store: ASC offer id (eyJ...) for + // introductoryOffer / promotionalOffer, or Play's + // basePlanId+offerId composite for offers. + id: v.string(), + kind: v.union( + v.literal("FreeTrial"), + v.literal("IntroPayUpFront"), + v.literal("IntroPayAsYouGo"), + v.literal("PromotionalOffer"), + v.literal("BasePlan"), + ), + // ISO-8601 duration the offer covers (e.g. "P7D", "P3M"). + // For BasePlan rows this is the recurring billing period. + duration: v.optional(v.string()), + // Number of billing periods the discounted/free price + // applies for (Apple's `numberOfPeriods`). Free trials and + // pay-up-front intros use 1; pay-as-you-go uses N. + numberOfPeriods: v.optional(v.number()), + priceAmountMicros: v.optional(v.number()), + currency: v.optional(v.string()), + }), + ), + ), + // Free-form note for App Store review. Maps to ASC's `reviewNote` + // attribute on inAppPurchases / subscriptions and is the field + // Apple's reviewer reads alongside the screenshot to understand + // how to trigger / verify the IAP. Length cap is 4000 chars on + // ASC's side; we don't enforce here so the operator gets Apple's + // own validation message if they exceed it. + reviewNote: v.optional(v.string()), + storeRef: v.optional(v.string()), + syncedAt: v.optional(v.number()), + updatedAt: v.number(), + }) + // Lookup row by (projectId, platform, productId). Apps commonly + // ship the SAME productId on both iOS and Android (e.g. + // `dev.hyo.martie.premium` exists in both stores), so the + // (projectId, productId)-only index would have collisions and + // silently flip an existing row's platform on sync. Including + // platform in the natural key keeps each store's catalog row + // separate. + .index("by_project_and_platform_and_product", [ + "projectId", + "platform", + "productId", + ]) + .index("by_project_and_platform", ["projectId", "platform"]), }); export default schema; diff --git a/packages/kit/convex/subscriptions/horizon.ts b/packages/kit/convex/subscriptions/horizon.ts new file mode 100644 index 00000000..ccffb315 --- /dev/null +++ b/packages/kit/convex/subscriptions/horizon.ts @@ -0,0 +1,304 @@ +"use node"; +import { createHash } from "node:crypto"; +import { v } from "convex/values"; + +import { action, internalAction } from "../_generated/server"; +import { internal } from "../_generated/api"; +import type { Id } from "../_generated/dataModel"; +import { mapWithConcurrency } from "../utils/concurrency"; + +// Horizon polling reconciler. +// +// Meta Horizon Store has no webhook / push notification system — +// `developers.meta.com/horizon/documentation/native/ps-iap` only +// exposes the synchronous `verify_entitlement` Graph API. So unlike +// Apple ASN v2 / Google RTDN, kit cannot ingest "subscription +// renewed" or "refunded" events the moment they happen on Meta's +// side; we have to re-check entitlement on a schedule. +// +// This cron action walks every Horizon `subscriptions` row that +// might have changed (state in {Active, InGracePeriod, Paused}), +// hits Meta Graph for each (userId, sku), and feeds the result +// through the same `applySubscriptionEvent` pipeline Apple/Google +// use. Net effect: subscriptions table converges to Meta's +// authoritative answer within one cron tick. +// +// Cadence: 6h (registered in `crons.ts`). Every project's Horizon +// subs run in one tick because the population is small per project. +// If a single project grows past ~1000 active Horizon subs we'll +// want to paginate. + +const META_GRAPH_BASE = "https://graph.oculus.com"; + +type HorizonProbe = { + userId: string; + sku: string; + purchaseToken: string; + state: string; +}; + +export const reconcileHorizonEntitlements = internalAction({ + args: {}, + returns: v.object({ + checked: v.number(), + transitioned: v.number(), + failures: v.number(), + }), + handler: async ( + ctx, + ): Promise<{ + checked: number; + transitioned: number; + failures: number; + }> => { + const projects = await ctx.runQuery( + internal.subscriptions.horizonInternal.listHorizonProjects, + {}, + ); + let checked = 0; + let transitioned = 0; + let failures = 0; + + for (const project of projects) { + if ( + !project.horizonEnabled || + !project.horizonAppId || + !project.horizonAppSecret + ) { + continue; + } + const probes = await ctx.runQuery( + internal.subscriptions.horizonInternal.listHorizonSubscriptions, + { projectId: project._id }, + ); + const appAccessToken = `OC|${project.horizonAppId}|${project.horizonAppSecret}`; + + // Parallelize Meta Graph API checks per project. Meta's + // verify_entitlement endpoint isn't tightly throttled — App + // Access Tokens get the standard Graph rate limit (~200 calls + // per app per hour per user, but our user is the App ID + // itself), so concurrency=8 keeps the cron tick fast for + // projects with many subs without tripping 429s. The runMutation + // calls inside still serialize per probe to keep the + // recordHorizonStatus state-transitions atomic. + const HORIZON_PROBE_CONCURRENCY = 8; + checked += probes.length; + const probeResults = await mapWithConcurrency( + probes, + HORIZON_PROBE_CONCURRENCY, + async (probe) => { + try { + const granted = await checkHorizonEntitlement({ + appId: project.horizonAppId!, + appAccessToken, + userId: probe.userId, + sku: probe.sku, + }); + return { probe, granted, error: null as unknown }; + } catch (error) { + return { probe, granted: null as boolean | null, error }; + } + }, + ); + for (const result of probeResults) { + const { probe, granted, error } = result; + if (error) { + failures += 1; + // Don't log the raw probe.userId / probe.sku — those are + // user-linked identifiers and end up in stdout / log + // aggregators long-term. The purchaseToken hash is enough + // to correlate this entry to the row in `subscriptions` + // when an operator needs to investigate. + console.warn( + "[horizon-reconciler] check failed", + project._id, + { tokenHash: hashForLog(probe.purchaseToken) }, + error instanceof Error ? error.message : error, + ); + continue; + } + // Meta's response is binary: `granted: true` means the user + // currently holds the entitlement. Map to the same event + // types Apple/Google emit so the state machine / entitlements + // query don't need a Horizon-specific branch. + // + // Increment `transitioned` only when recordHorizonStatus + // returns a non-null subscription id — it returns null when + // there's no matching subscription row to transition (e.g. + // the kit-side row was never created), in which case we + // didn't actually mutate anything. + if (granted && probe.state !== "Active") { + const updated = await ctx.runMutation( + internal.subscriptions.horizonInternal.recordHorizonStatus, + { + projectId: project._id, + purchaseToken: probe.purchaseToken, + userId: probe.userId, + productId: probe.sku, + eventType: "SubscriptionRenewed", + }, + ); + if (updated) transitioned += 1; + } else if (!granted && probe.state === "Active") { + const updated = await ctx.runMutation( + internal.subscriptions.horizonInternal.recordHorizonStatus, + { + projectId: project._id, + purchaseToken: probe.purchaseToken, + userId: probe.userId, + productId: probe.sku, + eventType: "SubscriptionExpired", + }, + ); + if (updated) transitioned += 1; + } + } + } + + return { checked, transitioned, failures }; + }, +}); + +// Manual one-off run trigger from the dashboard "Reconcile now" button +// or the MCP `openiap_troubleshoot` tool. Same handler as the cron +// path; just exposed under a public action for convenience. +export const reconcileHorizonNow = action({ + args: { apiKey: v.string() }, + returns: v.object({ + checked: v.number(), + transitioned: v.number(), + failures: v.number(), + }), + handler: async ( + ctx, + args, + ): Promise<{ + checked: number; + transitioned: number; + failures: number; + }> => { + const project = await ctx.runQuery( + internal.subscriptions.horizonInternal.getProjectByApiKey, + { apiKey: args.apiKey }, + ); + if (!project) throw new Error("Invalid API key"); + if ( + !project.horizonEnabled || + !project.horizonAppId || + !project.horizonAppSecret + ) { + throw new Error( + "Horizon is not configured for this project (set horizonEnabled + horizonAppId + horizonAppSecret in Settings).", + ); + } + const probes = await ctx.runQuery( + internal.subscriptions.horizonInternal.listHorizonSubscriptions, + { projectId: project._id }, + ); + const appAccessToken = `OC|${project.horizonAppId}|${project.horizonAppSecret}`; + + let checked = 0; + let transitioned = 0; + let failures = 0; + for (const probe of probes) { + checked += 1; + try { + const granted = await checkHorizonEntitlement({ + appId: project.horizonAppId, + appAccessToken, + userId: probe.userId, + sku: probe.sku, + }); + // See the matching note in reconcileHorizonEntitlements: only + // increment when recordHorizonStatus actually returned a + // subscription id (null = no matching row, no transition). + if (granted && probe.state !== "Active") { + const updated = await ctx.runMutation( + internal.subscriptions.horizonInternal.recordHorizonStatus, + { + projectId: project._id, + purchaseToken: probe.purchaseToken, + userId: probe.userId, + productId: probe.sku, + eventType: "SubscriptionRenewed", + }, + ); + if (updated) transitioned += 1; + } else if (!granted && probe.state === "Active") { + const updated = await ctx.runMutation( + internal.subscriptions.horizonInternal.recordHorizonStatus, + { + projectId: project._id, + purchaseToken: probe.purchaseToken, + userId: probe.userId, + productId: probe.sku, + eventType: "SubscriptionExpired", + }, + ); + if (updated) transitioned += 1; + } + } catch (error) { + failures += 1; + console.warn("[horizon-reconciler] check failed", error); + } + } + return { checked, transitioned, failures }; + }, +}); + +// Per-request timeout for the Meta Graph call. Without this, a hung +// upstream stalls the cron action indefinitely; the action's outer +// 10-min ceiling would still fire, but the tick would burn most of +// that budget on a single dead probe instead of moving on. 10s is +// generous for a single Graph endpoint while still letting a stalled +// project's cron tick complete in a reasonable wall time. +const HORIZON_FETCH_TIMEOUT_MS = 10_000; + +async function checkHorizonEntitlement(args: { + appId: string; + appAccessToken: string; + userId: string; + sku: string; +}): Promise { + const url = `${META_GRAPH_BASE}/${encodeURIComponent(args.appId)}/verify_entitlement`; + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + HORIZON_FETCH_TIMEOUT_MS, + ); + let res: Response; + try { + res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + signal: controller.signal, + body: new URLSearchParams({ + access_token: args.appAccessToken, + user_id: args.userId, + sku: args.sku, + }).toString(), + }); + } finally { + clearTimeout(timeout); + } + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Meta Graph API ${res.status}: ${text.slice(0, 256)}`); + } + const body = (await res.json()) as { success?: boolean }; + return body.success === true; +} + +// Privacy-safe one-way fingerprint of a purchase token for log lines. +// We only need enough entropy to disambiguate "the same row keeps +// failing" vs "every probe is failing"; truncating SHA-1 to 12 hex +// chars (~48 bits) is collision-resistant enough to identify a row +// without surfacing the original identifier in stdout. +function hashForLog(input: string): string { + return createHash("sha1").update(input).digest("hex").slice(0, 12); +} + +// Re-export with proper Id type usage so consumers in the same module +// graph compile cleanly even though we pass `Id<"projects">` around. +export type HorizonProjectId = Id<"projects">; +export type HorizonProbeRow = HorizonProbe; diff --git a/packages/kit/convex/subscriptions/horizonInternal.ts b/packages/kit/convex/subscriptions/horizonInternal.ts new file mode 100644 index 00000000..464b7a85 --- /dev/null +++ b/packages/kit/convex/subscriptions/horizonInternal.ts @@ -0,0 +1,306 @@ +import { internalMutation, internalQuery } from "../_generated/server"; +import { v } from "convex/values"; +import type { Doc } from "../_generated/dataModel"; + +import { + applySubscriptionTransition, + type CurrentSubscription, +} from "./stateMachine"; +import { applyStatsTransition, statsContributionFor } from "./stats"; + +// Convex-runtime helpers used by the Horizon polling reconciler in +// `horizon.ts`. Kept separate so the action's "use node" boundary +// doesn't drag node-only imports into the regular Convex bundle. + +export const listHorizonProjects = internalQuery({ + args: {}, + returns: v.array( + v.object({ + _id: v.id("projects"), + horizonEnabled: v.optional(v.boolean()), + horizonAppId: v.optional(v.union(v.string(), v.null())), + horizonAppSecret: v.optional(v.union(v.string(), v.null())), + }), + ), + handler: async (ctx) => { + // Use the by_horizon_enabled index instead of a full-table scan. + // Most projects don't opt into Meta Horizon, so this skips the + // bulk of the table on every cron tick. + const enabled = await ctx.db + .query("projects") + .withIndex("by_horizon_enabled", (q) => q.eq("horizonEnabled", true)) + .collect(); + return enabled.map((project) => ({ + _id: project._id, + horizonEnabled: project.horizonEnabled, + horizonAppId: project.horizonAppId, + horizonAppSecret: project.horizonAppSecret, + })); + }, +}); + +export const getProjectByApiKey = internalQuery({ + args: { apiKey: v.string() }, + returns: v.union( + v.null(), + v.object({ + _id: v.id("projects"), + horizonEnabled: v.optional(v.boolean()), + horizonAppId: v.optional(v.union(v.string(), v.null())), + horizonAppSecret: v.optional(v.union(v.string(), v.null())), + }), + ), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + if (!project) return null; + return { + _id: project._id, + horizonEnabled: project.horizonEnabled, + horizonAppId: project.horizonAppId, + horizonAppSecret: project.horizonAppSecret, + }; + }, +}); + +// All subscriptions for a Horizon project that might still mutate. +// Refunded/Revoked/Expired-with-no-renewal rows are excluded so the +// cron stays cheap as the historical archive grows. +export const listHorizonSubscriptions = internalQuery({ + args: { projectId: v.id("projects") }, + returns: v.array( + v.object({ + userId: v.string(), + sku: v.string(), + purchaseToken: v.string(), + state: v.string(), + }), + ), + handler: async (ctx, args) => { + // Hit by_project_and_state for each mutable state in parallel + // instead of full-scanning the project via by_project_and_updated + // and filtering in memory. The Refunded / Revoked / Expired + // historical archive is the bulk of any long-lived project — the + // index path skips it entirely. + // All states that can still mutate via Meta's verify_entitlement + // result. The historical archive (Refunded / Revoked / Expired + // with no auto-renew) is excluded so the cron stays cheap as the + // archive grows, but every live + transient state is included + // so a recovery (InBillingRetry → Active) or a Paused → expiry + // doesn't get stuck. + const STATES = [ + "Active", + "InGracePeriod", + "InBillingRetry", + "Paused", + "Unknown", + ] as const; + // Per-state cap with self-paginating, oldest-first ordering. + // + // Bounded for two reasons: (1) Convex's 40k document-read limit + // per query — 5 states × 6_000 = 30k reads, leaving ~10k for + // downstream filtering; (2) the action that consumes this list + // calls Meta `verify_entitlement` once per row, which has its + // own per-cron-tick budget. + // + // Pagination strategy: order by `updatedAt` ASC via the + // `by_project_and_state_and_updated` composite index. The + // staleest subs per state surface first; once + // `recordHorizonStatus` runs and writes a fresh `updatedAt`, + // those rows move to the back of the queue so the next tick + // picks up the never-reconciled tail. Time-to-fully-reconcile + // for population N is ~ceil(N / PER_STATE_CAP) ticks. A + // pathological 100k-sub project converges in ~17 ticks instead + // of "tail forever stale" (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + // + // No external continuation cursor is needed because the cursor is + // implicit in `updatedAt` itself. + const PER_STATE_CAP = 6_000; + const perState = await Promise.all( + STATES.map((state) => + ctx.db + .query("subscriptions") + .withIndex("by_project_and_state_and_updated", (q) => + q.eq("projectId", args.projectId).eq("state", state), + ) + .order("asc") + .take(PER_STATE_CAP), + ), + ); + // Operator visibility: log when a state bucket fully fills the + // per-tick cap. The reconciler still completes correctly because + // the tail surfaces next tick, but a sustained cap-hit signals + // that the cron interval may be too sparse for the population. + STATES.forEach((state, i) => { + if (perState[i].length === PER_STATE_CAP) { + console.info( + `[horizon-reconciler] project=${args.projectId} state=${state} filled PER_STATE_CAP=${PER_STATE_CAP}; remaining tail will reconcile on subsequent ticks via updatedAt cursor.`, + ); + } + }); + return perState + .flat() + .filter((sub) => sub.platform === "Android") + .filter((sub) => !!sub.userId) + .map((sub) => ({ + userId: sub.userId!, + sku: sub.productId, + purchaseToken: sub.purchaseToken, + state: sub.state, + })); + }, +}); + +// The reconciler hands us a synthetic "event" describing what Meta +// just told us. We funnel it through the same state-machine the +// webhook receivers use so transition semantics stay consistent. +export const recordHorizonStatus = internalMutation({ + args: { + projectId: v.id("projects"), + purchaseToken: v.string(), + userId: v.string(), + productId: v.string(), + eventType: v.union( + v.literal("SubscriptionRenewed"), + v.literal("SubscriptionExpired"), + ), + }, + returns: v.union(v.null(), v.id("subscriptions")), + handler: async (ctx, args) => { + const existing: Doc<"subscriptions"> | null = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_token", (q) => + q + .eq("projectId", args.projectId) + .eq("purchaseToken", args.purchaseToken), + ) + .unique(); + if (!existing) return null; + + const current: CurrentSubscription = { + state: existing.state, + productId: existing.productId, + expiresAt: existing.expiresAt, + renewsAt: existing.renewsAt, + willRenew: existing.willRenew, + cancellationReason: existing.cancellationReason, + currency: existing.currency, + priceAmountMicros: existing.priceAmountMicros, + }; + const transition = applySubscriptionTransition(current, { + type: args.eventType, + productId: args.productId, + }); + if (!transition.next) return existing._id; + const now = Date.now(); + + // Synthesize a webhookEvents row so the SSE stream re-broadcasts + // this Horizon transition to connected SDK clients. Without this + // the polling reconciler updated the subscription row but never + // surfaced the change on `/v1/webhooks/stream/{apiKey}` — Horizon + // listeners would silently miss every renewal / expiry until the + // next state-driven HTTP query. + // + // Source is `MetaHorizonReconciler` (synthetic; Horizon has no + // upstream webhook) and `sourceNotificationId` is a deterministic + // hash of (purchaseToken, eventType, productId) so re-running the + // cron with the same Meta Graph response doesn't double-emit. + const sourceNotificationId = `meta-horizon-${args.eventType}-${args.purchaseToken}-${args.productId}`; + + // Dedup by (projectId, source, sourceNotificationId) — re-running + // the same Horizon poll result (cron retries, manual reconcile) + // would otherwise insert another webhookEvents row and re-broadcast + // the same SSE event, bypassing the first-seen-wins contract the + // Apple/Google webhook receivers honor. Reuse the existing event + // when one is already on file. + const existingEvent = await ctx.db + .query("webhookEvents") + .withIndex("by_project_and_notification_id", (q) => + q + .eq("projectId", args.projectId) + .eq("sourceNotificationId", sourceNotificationId), + ) + .unique(); + const eventId = existingEvent + ? existingEvent._id + : await ctx.db.insert("webhookEvents", { + projectId: args.projectId, + type: args.eventType, + source: "MetaHorizonReconciler", + platform: "Android", + environment: "Production", + purchaseToken: args.purchaseToken, + sourceNotificationId, + productId: args.productId, + subscriptionState: transition.next.state, + occurredAt: now, + receivedAt: now, + }); + // If we found an existing event AND the existing subscription row + // already references it, the rest of this mutation is a no-op — + // the prior cron tick already applied this transition. Bump + // `updatedAt` so the row moves to the back of the + // `by_project_and_state_and_updated` queue used by + // `listHorizonSubscriptions` for paginated reconciliation; + // otherwise steady-state rows whose deterministic event id + // doesn't change would stay pinned at the front of the cursor and + // anything past PER_STATE_CAP would never be revisited (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + if (existing.lastEventId === eventId) { + await ctx.db.patch(existing._id, { updatedAt: now }); + return existing._id; + } + + // Capture stats contribution before patching so the delta below + // subtracts what the row used to count for and adds the new state. + // Horizon doesn't track billingPeriod (Meta doesn't expose one in + // verify_entitlement), so MRR contribution is 0 — matches the + // existing read-path semantics for Horizon-backed subs. + const beforeContribution = statsContributionFor(existing, undefined, now); + + // Horizon-specific expiresAt handling. Meta's verify_entitlement + // is binary (granted / not granted) — there's no upstream expiry + // we can copy onto the row. The state machine's CurrentSubscription + // path carries the OLD expiresAt forward, which means a renewed- + // upstream sub whose previous expiresAt is now in the past would + // be patched back to "Active" with a stale (already-expired) + // timestamp; the entitlement read path's `isActive` check then + // immediately treats it as inactive again. Set a forward-looking + // expiry that comfortably outlasts the next poll cycle (cron runs + // every 6h) so an `Active` Horizon row stays entitled until either + // the next reconcile flips it or the operator pauses the cron for + // an extended outage. + // + // For SubscriptionExpired we let the state-machine's transition + // handle the timestamp; the row is moving to a non-active state + // so the stale expiresAt is irrelevant. + const HORIZON_RENEWAL_VALIDITY_MS = 7 * 24 * 60 * 60 * 1000; + const horizonExpiresAt = + args.eventType === "SubscriptionRenewed" + ? now + HORIZON_RENEWAL_VALIDITY_MS + : transition.next.expiresAt; + + await ctx.db.patch(existing._id, { + state: transition.next.state, + willRenew: transition.next.willRenew, + cancellationReason: transition.next.cancellationReason, + expiresAt: horizonExpiresAt, + updatedAt: now, + lastEventId: eventId, + }); + + const updatedRow = (await ctx.db.get(existing._id))!; + const afterContribution = statsContributionFor(updatedRow, undefined, now); + await applyStatsTransition( + ctx, + args.projectId, + beforeContribution, + afterContribution, + ); + + return existing._id; + }, +}); diff --git a/packages/kit/convex/subscriptions/internal.ts b/packages/kit/convex/subscriptions/internal.ts new file mode 100644 index 00000000..1ffeaee3 --- /dev/null +++ b/packages/kit/convex/subscriptions/internal.ts @@ -0,0 +1,271 @@ +import { internalMutation, type MutationCtx } from "../_generated/server"; +import { v, type Infer } from "convex/values"; +import type { Doc, Id } from "../_generated/dataModel"; + +import { + applySubscriptionTransition, + type CurrentSubscription, + type SubscriptionEventInput, +} from "./stateMachine"; +import { applyStatsTransition, statsContributionFor } from "./stats"; + +const subscriptionStateValidator = v.union( + v.literal("Active"), + v.literal("InGracePeriod"), + v.literal("InBillingRetry"), + v.literal("Expired"), + v.literal("Revoked"), + v.literal("Refunded"), + v.literal("Paused"), + v.literal("Unknown"), +); + +const eventInputValidator = v.object({ + type: v.string(), + productId: v.optional(v.string()), + subscriptionState: v.optional(subscriptionStateValidator), + expiresAt: v.optional(v.number()), + renewsAt: v.optional(v.number()), + cancellationReason: v.optional(v.string()), + currency: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + platform: v.union(v.literal("IOS"), v.literal("Android")), + purchaseToken: v.string(), +}); + +type RawEventInput = Infer; + +// Apply a webhook event to the canonical `subscriptions` table. Idempotent +// with respect to `lastEventId` so a re-run of the same event (after a +// retry / replay) doesn't double-count metrics. +export const applySubscriptionEvent = internalMutation({ + args: { + projectId: v.id("projects"), + eventId: v.id("webhookEvents"), + event: eventInputValidator, + }, + returns: v.object({ + transition: v.union(v.string(), v.null()), + active: v.boolean(), + subscriptionId: v.optional(v.id("subscriptions")), + }), + handler: async (ctx, args) => { + const existing = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_token", (q) => + q + .eq("projectId", args.projectId) + .eq("purchaseToken", args.event.purchaseToken), + ) + .unique(); + + if (existing && existing.lastEventId === args.eventId) { + return { + transition: null, + active: isActive(existing), + subscriptionId: existing._id, + }; + } + + const current: CurrentSubscription = existing + ? { + state: existing.state, + productId: existing.productId, + expiresAt: existing.expiresAt, + renewsAt: existing.renewsAt, + willRenew: existing.willRenew, + cancellationReason: existing.cancellationReason, + currency: existing.currency, + priceAmountMicros: existing.priceAmountMicros, + } + : null; + + const transition = applySubscriptionTransition( + current, + coerceEventInput(args.event), + ); + + if (!transition.next) { + return { + transition: transition.transition ?? null, + active: false, + subscriptionId: existing?._id, + }; + } + + const now = Date.now(); + const next = transition.next; + + // Pull billing period for MRR calculation. Skipped if state isn't + // counted (Active / InGracePeriod / InBillingRetry) since + // statsContributionFor returns null in that case anyway. The + // AFTER side always uses the new product's period. + const billingPeriod = await fetchBillingPeriod( + ctx, + args.projectId, + args.event.platform, + next.productId, + ); + + // BEFORE side has to use the OLD product's billing period — when + // an upgrade or downgrade event flips `productId`, using the new + // product's period to compute the BEFORE delta would subtract + // the wrong monthly-normalized amount from MRR and corrupt the + // incremental counter (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + // Reuse `billingPeriod` only when the productId didn't change. + const beforeBillingPeriod = + existing && existing.productId !== next.productId + ? await fetchBillingPeriod( + ctx, + args.projectId, + args.event.platform, + existing.productId, + ) + : billingPeriod; + + // Capture the BEFORE contribution against the still-existing row + // so the stats delta below subtracts what the row used to count + // for, then adds what it counts for after the patch. + const beforeContribution = existing + ? statsContributionFor(existing, beforeBillingPeriod, now) + : null; + + let subscriptionId: Id<"subscriptions">; + let updatedRow: Doc<"subscriptions">; + if (existing) { + await ctx.db.patch(existing._id, { + productId: next.productId, + state: next.state, + expiresAt: next.expiresAt, + renewsAt: next.renewsAt, + willRenew: next.willRenew, + cancellationReason: next.cancellationReason, + currency: next.currency, + priceAmountMicros: next.priceAmountMicros, + updatedAt: now, + lastEventId: args.eventId, + }); + subscriptionId = existing._id; + updatedRow = (await ctx.db.get(existing._id))!; + } else { + subscriptionId = await ctx.db.insert("subscriptions", { + projectId: args.projectId, + purchaseToken: args.event.purchaseToken, + productId: next.productId, + platform: args.event.platform, + state: next.state, + expiresAt: next.expiresAt, + renewsAt: next.renewsAt, + willRenew: next.willRenew, + cancellationReason: next.cancellationReason, + currency: next.currency, + priceAmountMicros: next.priceAmountMicros, + startedAt: now, + updatedAt: now, + lastEventId: args.eventId, + }); + updatedRow = (await ctx.db.get(subscriptionId))!; + } + + const afterContribution = statsContributionFor( + updatedRow, + billingPeriod, + now, + ); + await applyStatsTransition( + ctx, + args.projectId, + beforeContribution, + afterContribution, + ); + + return { + transition: transition.transition ?? null, + active: transition.active, + subscriptionId, + }; + }, +}); + +// Look up a product's billing period from the kit-side catalog. We +// Look up the row for the EXACT (platform, productId) — `products` is +// keyed by (projectId, platform, productId) precisely because the +// same SKU can exist on both stores with different billing periods. +// Earlier behaviour preferred iOS over Android by walking both +// platforms, which made an Android subscription inherit the iOS +// period when those rows diverged and skewed `mrrMicros` on both the +// incremental delta and the next recompute (PR #124 +// (https://github.com/hyodotdev/openiap/pull/124) review). Returns +// undefined when the product isn't tracked or has no billingPeriod — +// monthlyMicrosForSub treats that as a P1M fallback. +async function fetchBillingPeriod( + ctx: MutationCtx, + projectId: Id<"projects">, + platform: "IOS" | "Android", + productId: string, +): Promise { + const product = await ctx.db + .query("products") + .withIndex("by_project_and_platform_and_product", (q) => + q + .eq("projectId", projectId) + .eq("platform", platform) + .eq("productId", productId), + ) + .unique(); + return product?.billingPeriod ?? undefined; +} + +function coerceEventInput(raw: RawEventInput): SubscriptionEventInput { + return { + type: raw.type as SubscriptionEventInput["type"], + productId: raw.productId, + subscriptionState: raw.subscriptionState, + expiresAt: raw.expiresAt, + renewsAt: raw.renewsAt, + cancellationReason: raw.cancellationReason as + | SubscriptionEventInput["cancellationReason"] + | undefined, + currency: raw.currency, + priceAmountMicros: raw.priceAmountMicros, + }; +} + +function isActive( + sub: Doc<"subscriptions">, + now: number = Date.now(), +): boolean { + const entitled = sub.state === "Active" || sub.state === "InGracePeriod"; + if (!entitled) return false; + if (sub.expiresAt != null && sub.expiresAt <= now) return false; + return true; +} + +// Bind a subscription to a userId. Called by the SDK after a successful +// receipt validation when the host app knows which user owns the receipt. +export const bindSubscriptionToUser = internalMutation({ + args: { + projectId: v.id("projects"), + purchaseToken: v.string(), + userId: v.string(), + }, + returns: v.union(v.id("subscriptions"), v.null()), + handler: async (ctx, args) => { + const sub = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_token", (q) => + q + .eq("projectId", args.projectId) + .eq("purchaseToken", args.purchaseToken), + ) + .unique(); + if (!sub) return null; + if (sub.userId === args.userId) return sub._id; + await ctx.db.patch(sub._id, { + userId: args.userId, + updatedAt: Date.now(), + }); + return sub._id; + }, +}); diff --git a/packages/kit/convex/subscriptions/monthlyMicros.ts b/packages/kit/convex/subscriptions/monthlyMicros.ts new file mode 100644 index 00000000..7bf3fc17 --- /dev/null +++ b/packages/kit/convex/subscriptions/monthlyMicros.ts @@ -0,0 +1,49 @@ +import type { Doc } from "../_generated/dataModel"; + +// Normalize a subscription's billing-period price to a per-month +// micros figure so MRR can sum across products with different billing +// periods. Formula uses calendar averages — yearly /12, weekly *4.345 +// (= 52.14/12), bi-weekly *2.17, daily *30.44 — chosen to land in the +// same order of magnitude as the standard SaaS MRR convention. The +// previous implementation summed `priceAmountMicros` raw, so a $120/yr +// plan inflated MRR by 12×. +// +// Lives in its own file so both `query.ts` (read path) and `stats.ts` +// (incremental aggregation) share the same calculation — splitting it +// into two copies would let MRR drift between the live counter and a +// future scan-based recomputation. +export function monthlyMicrosForSub( + sub: Doc<"subscriptions">, + productPeriod: string | undefined, +): number { + if (typeof sub.priceAmountMicros !== "number") return 0; + const amount = sub.priceAmountMicros; + switch (productPeriod) { + case "P1Y": + return Math.round(amount / 12); + case "P6M": + return Math.round(amount / 6); + case "P3M": + return Math.round(amount / 3); + case "P2M": + return Math.round(amount / 2); + case "P1W": + return Math.round(amount * 4.345); + case "P3D": + return Math.round(amount * (30.44 / 3)); + case "P2W": + return Math.round(amount * (30.44 / 14)); + case "P1M": + return amount; + case undefined: + default: + // One-time products (NonConsumable / Consumable) and rows + // with missing billing metadata don't contribute to recurring + // revenue. The previous fall-through to `amount` inflated MRR + // by the full sticker price every time a one-time purchase + // landed in `subscriptions` — which only happens when a + // catalog row was mis-classified, but a mis-classification + // shouldn't quietly skew the dashboard headline. + return 0; + } +} diff --git a/packages/kit/convex/subscriptions/mutation.ts b/packages/kit/convex/subscriptions/mutation.ts new file mode 100644 index 00000000..5efc45bf --- /dev/null +++ b/packages/kit/convex/subscriptions/mutation.ts @@ -0,0 +1,41 @@ +import { mutation } from "../_generated/server"; +import { v } from "convex/values"; +import type { Doc } from "../_generated/dataModel"; + +// Public mutation called by SDKs after a successful receipt verification: +// they know who the host-app user is, so they tell kit which userId owns +// the verified purchaseToken. Idempotent — re-binding the same userId is +// a no-op. +export const bindUser = mutation({ + args: { + apiKey: v.string(), + purchaseToken: v.string(), + userId: v.string(), + }, + returns: v.object({ ok: v.boolean(), bound: v.boolean() }), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + if (!project) return { ok: false, bound: false }; + + const sub: Doc<"subscriptions"> | null = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_token", (q) => + q.eq("projectId", project._id).eq("purchaseToken", args.purchaseToken), + ) + .unique(); + if (!sub) return { ok: true, bound: false }; + + if (sub.userId === args.userId) { + return { ok: true, bound: true }; + } + + await ctx.db.patch(sub._id, { + userId: args.userId, + updatedAt: Date.now(), + }); + return { ok: true, bound: true }; + }, +}); diff --git a/packages/kit/convex/subscriptions/query.ts b/packages/kit/convex/subscriptions/query.ts new file mode 100644 index 00000000..82337e85 --- /dev/null +++ b/packages/kit/convex/subscriptions/query.ts @@ -0,0 +1,461 @@ +import { query, type QueryCtx } from "../_generated/server"; +import { v } from "convex/values"; +import type { Doc, Id } from "../_generated/dataModel"; + +import { monthlyMicrosForSub } from "./monthlyMicros"; +import { selectMostRecentlyUpdatedSubscription } from "./selectLatest"; + +const subscriptionStateValidator = v.union( + v.literal("Active"), + v.literal("InGracePeriod"), + v.literal("InBillingRetry"), + v.literal("Expired"), + v.literal("Revoked"), + v.literal("Refunded"), + v.literal("Paused"), + v.literal("Unknown"), +); + +const subscriptionShape = v.object({ + id: v.id("subscriptions"), + productId: v.string(), + platform: v.union(v.literal("IOS"), v.literal("Android")), + state: subscriptionStateValidator, + expiresAt: v.optional(v.number()), + renewsAt: v.optional(v.number()), + willRenew: v.optional(v.boolean()), + cancellationReason: v.optional(v.string()), + currency: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + startedAt: v.number(), + updatedAt: v.number(), + purchaseToken: v.string(), + userId: v.optional(v.string()), +}); + +function isActive(sub: Doc<"subscriptions">, now: number): boolean { + const entitled = sub.state === "Active" || sub.state === "InGracePeriod"; + if (!entitled) return false; + if (sub.expiresAt != null && sub.expiresAt <= now) return false; + return true; +} + +function shapeRow(sub: Doc<"subscriptions">) { + return { + id: sub._id, + productId: sub.productId, + platform: sub.platform, + state: sub.state, + expiresAt: sub.expiresAt, + renewsAt: sub.renewsAt, + willRenew: sub.willRenew, + cancellationReason: sub.cancellationReason, + currency: sub.currency, + priceAmountMicros: sub.priceAmountMicros, + startedAt: sub.startedAt, + updatedAt: sub.updatedAt, + purchaseToken: sub.purchaseToken, + userId: sub.userId, + }; +} + +async function projectByApiKey( + ctx: { db: any }, + apiKey: string, +): Promise | null> { + return await ctx.db + .query("projects") + .withIndex("by_api_key", (q: any) => q.eq("apiKey", apiKey)) + .unique(); +} + +// Match onesub's `/onesub/status?userId=` — returns the most-recently- +// updated active subscription when the user is entitled, otherwise the +// most-recently-updated subscription overall, plus one `active` boolean +// for simple gating. +export const subscriptionStatus = query({ + args: { apiKey: v.string(), userId: v.string() }, + returns: v.object({ + active: v.boolean(), + subscription: v.union(subscriptionShape, v.null()), + }), + handler: async (ctx, args) => { + const project = await projectByApiKey(ctx, args.apiKey); + if (!project) return { active: false, subscription: null }; + + const subs = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_user", (q) => + q.eq("projectId", project._id).eq("userId", args.userId), + ) + .collect(); + + const now = Date.now(); + const activeSubs = subs.filter((candidate) => isActive(candidate, now)); + const sub = selectMostRecentlyUpdatedSubscription( + activeSubs.length > 0 ? activeSubs : subs, + ); + if (!sub) return { active: false, subscription: null }; + + return { + active: activeSubs.length > 0, + subscription: shapeRow(sub), + }; + }, +}); + +// Match onesub's entitlement evaluation — every productId the user +// currently has rights to. Aggregates across all subscription rows so +// a user with multiple offers (resub, family share, cross-grade) sees +// the union. +export const entitlements = query({ + args: { apiKey: v.string(), userId: v.string() }, + returns: v.object({ + userId: v.string(), + productIds: v.array(v.string()), + subscriptions: v.array(subscriptionShape), + }), + handler: async (ctx, args) => { + const project = await projectByApiKey(ctx, args.apiKey); + if (!project) { + return { userId: args.userId, productIds: [], subscriptions: [] }; + } + + const all = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_user", (q) => + q.eq("projectId", project._id).eq("userId", args.userId), + ) + .collect(); + + const now = Date.now(); + const active = all.filter((sub) => isActive(sub, now)); + return { + userId: args.userId, + productIds: Array.from(new Set(active.map((sub) => sub.productId))), + subscriptions: active.map(shapeRow), + }; + }, +}); + +// Filtered list for the dashboard's subscriptions page. Mirrors +// onesub's `SubscriptionStore.listFiltered` API. +export const listSubscriptions = query({ + args: { + apiKey: v.string(), + state: v.optional(subscriptionStateValidator), + productId: v.optional(v.string()), + userId: v.optional(v.string()), + limit: v.optional(v.number()), + }, + returns: v.object({ + items: v.array(subscriptionShape), + total: v.number(), + }), + handler: async (ctx, args) => { + const project = await projectByApiKey(ctx, args.apiKey); + if (!project) return { items: [], total: 0 }; + + const limit = Math.min(Math.max(args.limit ?? 50, 1), 200); + + // userId path: subscriptions per user is a small population + // (single digits in practice — a user with 50 subscriptions on a + // single project is pathological), so we collect the entire + // by_project_and_user slice and apply state/productId filters in + // memory rather than throwing. Earlier behaviour rejected the + // combo with an error, which made the dashboard "filter user X by + // state Active" path unusable (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + if (args.userId) { + const userRows = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_user", (q) => + q.eq("projectId", project._id).eq("userId", args.userId), + ) + .order("desc") + .collect(); + const filtered = userRows.filter((sub) => { + if (args.state && sub.state !== args.state) return false; + if (args.productId && sub.productId !== args.productId) return false; + return true; + }); + return { + items: filtered.slice(0, limit).map(shapeRow), + total: filtered.length, + }; + } + + // Pick the most-selective index for the supplied filters. Schema + // covers single-filter combinations directly; the composite + // (projectId, state, productId) index handles the dashboard's + // common "filter by state and SKU" combination so we don't need + // an over-fetch + in-memory post-filter that could miss rows + // past the take() boundary. + let rows: Array>; + if (args.state && args.productId) { + rows = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_state_and_product", (q) => + q + .eq("projectId", project._id) + .eq("state", args.state!) + .eq("productId", args.productId!), + ) + .order("desc") + .take(limit); + } else if (args.state) { + rows = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_state", (q) => + q.eq("projectId", project._id).eq("state", args.state!), + ) + .order("desc") + .take(limit); + } else if (args.productId) { + rows = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_product", (q) => + q.eq("projectId", project._id).eq("productId", args.productId!), + ) + .order("desc") + .take(limit); + } else { + rows = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_updated", (q) => + q.eq("projectId", project._id), + ) + .order("desc") + .take(limit); + } + + // All filter combinations hit an index that covers the supplied + // columns now (the (state + productId) composite was added in + // schema.ts), so no in-memory post-filter is needed here. + + // `total` reflects the filtered window we actually materialized, + // not the full server-side count. Computing a true total would + // require a separate aggregate scan that defeats the take() bound + // we just put in. The dashboard treats `total` as "rows shown + // matching the current filter" and surfaces "+ more" affordances + // via the next page request. + return { items: rows.slice(0, limit).map(shapeRow), total: rows.length }; + }, +}); + +// Metrics aggregation. Reads incrementally-maintained per-currency +// counters out of `subscriptionStats` for the live state buckets + +// MRR (O(currencies-per-project) — typically 1-3 rows), and bounded +// indexed scans over `by_project_and_state` for the 30-day rolling +// counters. The prior implementation took up to 10,000 subscriptions +// off the by_project_and_updated index and aggregated in memory, +// which silently undercounted projects above that cap. +// +// Migration safety: when the stats table is empty for a project +// (pre-rollout state) we fall through to a one-shot recompute via +// the same statsContributionFor logic so the dashboard stays +// correct on first read after deploy. The +// `recomputeSubscriptionStats` internal mutation populates rows for +// future reads. +export const metricsSummary = query({ + args: { apiKey: v.string() }, + returns: v.object({ + activeSubs: v.number(), + inGracePeriod: v.number(), + inBillingRetry: v.number(), + refunded30d: v.number(), + canceled30d: v.number(), + // Headline MRR in the project's most-popular currency, normalized + // to monthly. Historical field name kept for backward compat with + // dashboard / MCP consumers. + mrrMicros: v.number(), + currency: v.optional(v.string()), + // Full per-currency breakdown so consumers that care about + // multi-currency aren't left guessing. Each entry's `mrrMicros` + // is summed only over subscriptions in that currency, normalized + // to monthly via the product's billingPeriod. + mrrByCurrency: v.array( + v.object({ currency: v.string(), mrrMicros: v.number() }), + ), + }), + handler: async (ctx, args) => { + const project = await projectByApiKey(ctx, args.apiKey); + if (!project) { + return { + activeSubs: 0, + inGracePeriod: 0, + inBillingRetry: 0, + refunded30d: 0, + canceled30d: 0, + mrrMicros: 0, + currency: undefined, + mrrByCurrency: [], + }; + } + + const now = Date.now(); + const cutoff = now - 30 * 24 * 60 * 60 * 1000; + + // Live state counters + MRR — read out of the incrementally + // maintained `subscriptionStats` table. + const statsRows = await ctx.db + .query("subscriptionStats") + .withIndex("by_project", (q) => q.eq("projectId", project._id)) + .collect(); + + let activeSubs = 0; + let inGracePeriod = 0; + let inBillingRetry = 0; + const mrrAccumulators = new Map(); + + if (statsRows.length > 0) { + for (const row of statsRows) { + activeSubs += row.activeSubs; + inGracePeriod += row.inGracePeriod; + inBillingRetry += row.inBillingRetry; + if (row.currency && row.mrrMicros > 0) { + mrrAccumulators.set( + row.currency, + (mrrAccumulators.get(row.currency) ?? 0) + row.mrrMicros, + ); + } + } + } else { + // No stats rows yet — pre-rollout state for this project. + // Compute on the fly so the dashboard isn't blank on first + // read after deploy. Bounded by the same per-project scan the + // backfill mutation does; for projects past the prior 10k cap + // this is a one-time cost until `recomputeSubscriptionStats` + // populates the table. + // + // Bounded by FALLBACK_SCAN_CAP so a project that's hugely past + // the prior 10k scan limit can't crash the dashboard render. + // The cap matches the previous implementation's bound; the + // first read after deploy schedules an async backfill via the + // drift-correction cron, after which subsequent reads come + // out of subscriptionStats and have no scan at all. + const FALLBACK_SCAN_CAP = 10_000; + const periodByProductId = await loadPeriodByProductId(ctx, project._id); + const allSubs = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_updated", (q) => + q.eq("projectId", project._id), + ) + .order("desc") + .take(FALLBACK_SCAN_CAP); + for (const sub of allSubs) { + if (sub.state === "Active" && isActive(sub, now)) { + activeSubs += 1; + if (typeof sub.priceAmountMicros === "number" && sub.currency) { + const monthly = monthlyMicrosForSub( + sub, + periodByProductId.get(sub.productId), + ); + mrrAccumulators.set( + sub.currency, + (mrrAccumulators.get(sub.currency) ?? 0) + monthly, + ); + } + } else if (sub.state === "InGracePeriod") { + inGracePeriod += 1; + } else if (sub.state === "InBillingRetry") { + inBillingRetry += 1; + } + } + } + + // 30-day rolling counters — bounded by churn rather than by + // historical state archive. The previous implementation walked + // every `Refunded` row + every (Active|InGracePeriod|InBillingRetry + // |Expired) row for the project and filtered in memory, which + // grew unbounded as the historical archive accumulated. We now + // do a single time-windowed scan via `by_project_and_updated` + // with `gte(cutoff)`, then derive both refunded + canceled + // counters in one pass. The candidate set is bounded by the + // last 30 days of state changes (typically thousands per + // project, never the full lifetime). + // Cap the windowed scan so a project with > 10k state changes + // in 30 days can't exceed Convex's 40k document-read limit. The + // rolling counters degrade gracefully — if a project genuinely + // hits this bound the dashboard shows an approximate count that + // still tracks the cohort closely (this is the same trade-off + // the previous SUBS_SCAN_CAP made for active counts, before the + // incremental subscriptionStats path replaced it). Real-world + // monthly churn is well under 10k for any realistic deployment. + const ROLLING_SCAN_CAP = 10_000; + const recentlyChanged = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_updated", (q) => + q.eq("projectId", project._id).gte("updatedAt", cutoff), + ) + .take(ROLLING_SCAN_CAP); + let refunded30d = 0; + let canceled30d = 0; + const CANCELED_STATES = new Set([ + "Active", + "InGracePeriod", + "InBillingRetry", + "Expired", + ]); + for (const sub of recentlyChanged) { + if (sub.state === "Refunded") { + refunded30d += 1; + } + if ( + sub.willRenew === false && + sub.cancellationReason === "UserCanceled" && + CANCELED_STATES.has(sub.state) + ) { + canceled30d += 1; + } + } + + // Pick the most-popular currency (largest accumulator) as the + // headline `currency` + `mrrMicros` so dashboards / MCP consumers + // that don't yet read the multi-currency breakdown still show a + // sensible single value. Stable tie-break via alphabetical sort. + const sorted = Array.from(mrrAccumulators.entries()).sort( + ([a, av], [b, bv]) => (bv !== av ? bv - av : a.localeCompare(b)), + ); + const headline = sorted[0]; + + return { + activeSubs, + inGracePeriod, + inBillingRetry, + refunded30d, + canceled30d, + mrrMicros: headline ? headline[1] : 0, + currency: headline ? headline[0] : undefined, + mrrByCurrency: sorted.map(([currency, mrrMicros]) => ({ + currency, + mrrMicros, + })), + }; + }, +}); + +async function loadPeriodByProductId( + ctx: QueryCtx, + projectId: Id<"projects">, +): Promise> { + const periodByProductId = new Map(); + for (const platform of ["IOS", "Android"] as const) { + const productRows = await ctx.db + .query("products") + .withIndex("by_project_and_platform", (q) => + q.eq("projectId", projectId).eq("platform", platform), + ) + .collect(); + for (const product of productRows) { + if ( + !periodByProductId.has(product.productId) || + (periodByProductId.get(product.productId) === undefined && + product.billingPeriod !== undefined) + ) { + periodByProductId.set(product.productId, product.billingPeriod); + } + } + } + return periodByProductId; +} diff --git a/packages/kit/convex/subscriptions/selectLatest.test.ts b/packages/kit/convex/subscriptions/selectLatest.test.ts new file mode 100644 index 00000000..efe9250a --- /dev/null +++ b/packages/kit/convex/subscriptions/selectLatest.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { selectMostRecentlyUpdatedSubscription } from "./selectLatest"; + +describe("selectMostRecentlyUpdatedSubscription", () => { + it("selects by updatedAt instead of input/index order", () => { + const selected = selectMostRecentlyUpdatedSubscription([ + { id: "newer-in-index-order", updatedAt: 100, _creationTime: 20 }, + { id: "renewed-later", updatedAt: 300, _creationTime: 10 }, + { id: "middle", updatedAt: 200, _creationTime: 30 }, + ]); + + expect(selected?.id).toBe("renewed-later"); + }); + + it("uses creation time as a deterministic tie-breaker", () => { + const selected = selectMostRecentlyUpdatedSubscription([ + { id: "first", updatedAt: 100, _creationTime: 10 }, + { id: "second", updatedAt: 100, _creationTime: 20 }, + ]); + + expect(selected?.id).toBe("second"); + }); + + it("returns null for an empty list", () => { + expect(selectMostRecentlyUpdatedSubscription([])).toBeNull(); + }); +}); diff --git a/packages/kit/convex/subscriptions/selectLatest.ts b/packages/kit/convex/subscriptions/selectLatest.ts new file mode 100644 index 00000000..cb2fa994 --- /dev/null +++ b/packages/kit/convex/subscriptions/selectLatest.ts @@ -0,0 +1,27 @@ +export type UpdatedSubscriptionCandidate = { + updatedAt: number; + _creationTime?: number; +}; + +export function selectMostRecentlyUpdatedSubscription< + T extends UpdatedSubscriptionCandidate, +>(subscriptions: readonly T[]): T | null { + let selected: T | null = null; + for (const subscription of subscriptions) { + if (!selected) { + selected = subscription; + continue; + } + if (subscription.updatedAt > selected.updatedAt) { + selected = subscription; + continue; + } + if ( + subscription.updatedAt === selected.updatedAt && + (subscription._creationTime ?? 0) > (selected._creationTime ?? 0) + ) { + selected = subscription; + } + } + return selected; +} diff --git a/packages/kit/convex/subscriptions/stateMachine.test.ts b/packages/kit/convex/subscriptions/stateMachine.test.ts new file mode 100644 index 00000000..ecc208c0 --- /dev/null +++ b/packages/kit/convex/subscriptions/stateMachine.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; +import { + applySubscriptionTransition, + entitlementActive, + type CurrentSubscription, +} from "./stateMachine"; + +const baseSub: NonNullable = { + state: "Active", + productId: "com.example.premium", + expiresAt: Date.now() + 3_600_000, + willRenew: true, +}; + +describe("applySubscriptionTransition", () => { + it("creates an Active row from SubscriptionStarted with no prior record", () => { + const result = applySubscriptionTransition(null, { + type: "SubscriptionStarted", + productId: "com.example.premium", + expiresAt: 2_000_000_000_000, + renewsAt: 2_000_000_000_000, + }); + expect(result.next?.state).toBe("Active"); + expect(result.active).toBe(true); + expect(result.transition).toBe("Started"); + }); + + it("treats SubscriptionStarted on top of an existing record as Recovered", () => { + const result = applySubscriptionTransition( + { ...baseSub, state: "Expired" }, + { + type: "SubscriptionStarted", + productId: baseSub.productId, + expiresAt: 2_000_000_000_000, + }, + ); + expect(result.next?.state).toBe("Active"); + expect(result.transition).toBe("Recovered"); + }); + + it("renews and keeps Active", () => { + const result = applySubscriptionTransition(baseSub, { + type: "SubscriptionRenewed", + productId: baseSub.productId, + expiresAt: 2_100_000_000_000, + }); + expect(result.next?.state).toBe("Active"); + expect(result.next?.expiresAt).toBe(2_100_000_000_000); + expect(result.active).toBe(true); + expect(result.transition).toBe("Renewed"); + }); + + it("Canceled keeps state Active until expiry but flips willRenew", () => { + const result = applySubscriptionTransition(baseSub, { + type: "SubscriptionCanceled", + productId: baseSub.productId, + }); + expect(result.next?.state).toBe("Active"); + expect(result.next?.willRenew).toBe(false); + expect(result.active).toBe(true); + expect(result.transition).toBe("Canceled"); + }); + + it("Uncanceled flips willRenew back to true", () => { + const canceled = { ...baseSub, willRenew: false }; + const result = applySubscriptionTransition(canceled, { + type: "SubscriptionUncanceled", + productId: baseSub.productId, + }); + expect(result.next?.willRenew).toBe(true); + expect(result.active).toBe(true); + expect(result.transition).toBe("Uncanceled"); + }); + + it("InGracePeriod keeps the user entitled", () => { + const result = applySubscriptionTransition(baseSub, { + type: "SubscriptionInGracePeriod", + productId: baseSub.productId, + }); + expect(result.next?.state).toBe("InGracePeriod"); + expect(result.active).toBe(true); + }); + + it("InBillingRetry de-entitles", () => { + const result = applySubscriptionTransition(baseSub, { + type: "SubscriptionInBillingRetry", + productId: baseSub.productId, + }); + expect(result.next?.state).toBe("InBillingRetry"); + expect(result.active).toBe(false); + }); + + it("Expired de-entitles and clears willRenew", () => { + const result = applySubscriptionTransition(baseSub, { + type: "SubscriptionExpired", + productId: baseSub.productId, + }); + expect(result.next?.state).toBe("Expired"); + expect(result.next?.willRenew).toBe(false); + expect(result.active).toBe(false); + }); + + it("Revoked is immediate de-entitlement", () => { + const result = applySubscriptionTransition(baseSub, { + type: "SubscriptionRevoked", + productId: baseSub.productId, + }); + expect(result.next?.state).toBe("Revoked"); + expect(result.next?.cancellationReason).toBe("Refunded"); + expect(result.active).toBe(false); + }); + + it("PurchaseRefunded with no current row records the refund without conjuring a sub", () => { + const result = applySubscriptionTransition(null, { + type: "PurchaseRefunded", + }); + expect(result.next).toBeNull(); + expect(result.active).toBe(false); + expect(result.transition).toBe("Refunded"); + }); + + it("PurchaseRefunded on an existing sub flips it to Refunded", () => { + const result = applySubscriptionTransition(baseSub, { + type: "PurchaseRefunded", + productId: baseSub.productId, + }); + expect(result.next?.state).toBe("Refunded"); + expect(result.active).toBe(false); + }); + + it("Paused / Resumed move state and entitlement together", () => { + const paused = applySubscriptionTransition(baseSub, { + type: "SubscriptionPaused", + productId: baseSub.productId, + }); + expect(paused.next?.state).toBe("Paused"); + expect(paused.active).toBe(false); + + const resumed = applySubscriptionTransition(paused.next, { + type: "SubscriptionResumed", + productId: baseSub.productId, + expiresAt: 2_500_000_000_000, + }); + expect(resumed.next?.state).toBe("Active"); + expect(resumed.active).toBe(true); + }); + + it("TestNotification and PurchaseConsumptionRequest do not mutate state", () => { + const test = applySubscriptionTransition(baseSub, { + type: "TestNotification", + }); + expect(test.next).toEqual(baseSub); + expect(test.transition).toBeNull(); + + const consumption = applySubscriptionTransition(baseSub, { + type: "PurchaseConsumptionRequest", + }); + expect(consumption.next).toEqual(baseSub); + }); +}); + +describe("entitlementActive", () => { + it("returns true for Active subs whose period has not yet expired", () => { + expect( + entitlementActive( + { state: "Active", productId: "p", expiresAt: 2_000 }, + 1_000, + ), + ).toBe(true); + }); + + it("returns false once the period has lapsed", () => { + expect( + entitlementActive( + { state: "Active", productId: "p", expiresAt: 1_000 }, + 2_000, + ), + ).toBe(false); + }); + + it("treats InGracePeriod as entitled", () => { + expect( + entitlementActive({ + state: "InGracePeriod", + productId: "p", + expiresAt: 2_000_000_000_000, + }), + ).toBe(true); + }); + + it("treats Expired / InBillingRetry / Revoked / Refunded / Paused as not entitled", () => { + for (const state of [ + "Expired", + "InBillingRetry", + "Revoked", + "Refunded", + "Paused", + ] as const) { + expect( + entitlementActive({ + state, + productId: "p", + expiresAt: 2_000_000_000_000, + }), + ).toBe(false); + } + }); +}); diff --git a/packages/kit/convex/subscriptions/stateMachine.ts b/packages/kit/convex/subscriptions/stateMachine.ts new file mode 100644 index 00000000..ad449800 --- /dev/null +++ b/packages/kit/convex/subscriptions/stateMachine.ts @@ -0,0 +1,279 @@ +// Pure state-machine that derives the next `subscriptions` row from a +// webhook event. Used by `applySubscriptionEvent` (the convex mutation +// driven by the webhook receiver) and unit-tested in isolation here so +// transition semantics aren't hidden behind ctx.db / Apple-SDK shims. + +import type { + WebhookEventType, + SubscriptionState, + WebhookCancellationReason, +} from "../webhooks/shared"; + +export type CurrentSubscription = { + state: SubscriptionState; + productId: string; + expiresAt?: number; + renewsAt?: number; + willRenew?: boolean; + cancellationReason?: WebhookCancellationReason; + currency?: string; + priceAmountMicros?: number; +} | null; + +export type SubscriptionEventInput = { + type: WebhookEventType; + productId?: string; + subscriptionState?: SubscriptionState; + expiresAt?: number; + renewsAt?: number; + cancellationReason?: WebhookCancellationReason; + currency?: string; + priceAmountMicros?: number; +}; + +export type SubscriptionTransition = { + // The next persistent state; null means "no record yet — this event + // does not create one" (e.g. an orphan REFUND with no prior purchase). + next: NonNullable | null; + // Whether the entitlement should be considered active for gating after + // applying this event. Mirrors the rule used by `/v1/subscriptions/status`. + active: boolean; + // Stable kind of transition for analytics (drives `revenueMetricsDaily`). + // `null` means "no-op" — the event was recorded but didn't change state. + transition: + | "Started" + | "Renewed" + | "Recovered" + | "EnteredGracePeriod" + | "EnteredBillingRetry" + | "Expired" + | "Canceled" + | "Uncanceled" + | "Revoked" + | "Refunded" + | "ProductChanged" + | "PriceChanged" + | "Paused" + | "Resumed" + | "Ignored" + | null; +}; + +const ENTITLED_STATES: ReadonlySet = new Set([ + "Active", + "InGracePeriod", +]); + +export function applySubscriptionTransition( + current: CurrentSubscription, + event: SubscriptionEventInput, +): SubscriptionTransition { + // Events that don't carry any subscription identity (TestNotification, + // PurchaseConsumptionRequest) never mutate the row. + if ( + event.type === "TestNotification" || + event.type === "PurchaseConsumptionRequest" + ) { + return { + next: current, + active: current ? entitlementActive(current) : false, + transition: null, + }; + } + + // PurchaseRefunded for one-time products without an existing record is + // an orphan — record it but don't conjure a subscription row. + if (event.type === "PurchaseRefunded" && !current) { + return { next: null, active: false, transition: "Refunded" }; + } + + const productId = event.productId ?? current?.productId; + if (!productId) { + // No way to bind the event to a subscription; leave state untouched. + return { + next: current, + active: current ? entitlementActive(current) : false, + transition: "Ignored", + }; + } + + const carryForward = (overrides: Partial>) => + ({ + state: overrides.state ?? current?.state ?? "Unknown", + productId, + expiresAt: overrides.expiresAt ?? current?.expiresAt, + renewsAt: overrides.renewsAt ?? current?.renewsAt, + willRenew: overrides.willRenew ?? current?.willRenew, + cancellationReason: + overrides.cancellationReason ?? current?.cancellationReason, + currency: overrides.currency ?? current?.currency, + priceAmountMicros: + overrides.priceAmountMicros ?? current?.priceAmountMicros, + }) as NonNullable; + + switch (event.type) { + case "SubscriptionStarted": { + const next = carryForward({ + state: "Active", + expiresAt: event.expiresAt, + renewsAt: event.renewsAt, + willRenew: true, + cancellationReason: undefined, + currency: event.currency, + priceAmountMicros: event.priceAmountMicros, + }); + return { + next, + active: true, + transition: current ? "Recovered" : "Started", + }; + } + case "SubscriptionRenewed": + return { + next: carryForward({ + state: "Active", + expiresAt: event.expiresAt, + renewsAt: event.renewsAt, + willRenew: true, + cancellationReason: undefined, + currency: event.currency ?? current?.currency, + priceAmountMicros: + event.priceAmountMicros ?? current?.priceAmountMicros, + }), + active: true, + transition: "Renewed", + }; + case "SubscriptionRecovered": + case "SubscriptionResumed": + return { + next: carryForward({ + state: "Active", + expiresAt: event.expiresAt, + renewsAt: event.renewsAt, + willRenew: true, + cancellationReason: undefined, + }), + active: true, + transition: + event.type === "SubscriptionResumed" ? "Resumed" : "Recovered", + }; + case "SubscriptionInGracePeriod": + return { + next: carryForward({ + state: "InGracePeriod", + expiresAt: event.expiresAt ?? current?.expiresAt, + }), + active: true, + transition: "EnteredGracePeriod", + }; + case "SubscriptionInBillingRetry": + return { + next: carryForward({ state: "InBillingRetry" }), + active: false, + transition: "EnteredBillingRetry", + }; + case "SubscriptionExpired": + return { + next: carryForward({ + state: "Expired", + willRenew: false, + cancellationReason: + event.cancellationReason ?? current?.cancellationReason, + }), + active: false, + transition: "Expired", + }; + case "SubscriptionCanceled": + // User turned off auto-renew but access continues until expiry. + // We keep `state: "Active"` (matches the spec note in + // `webhook.graphql` and onesub's behavior) and just flip willRenew. + return { + next: carryForward({ + state: + current && current.state === "Active" ? "Active" : current?.state, + willRenew: false, + cancellationReason: event.cancellationReason ?? "UserCanceled", + }), + active: current + ? entitlementActive({ ...current, willRenew: false }) + : false, + transition: "Canceled", + }; + case "SubscriptionUncanceled": + return { + next: carryForward({ + willRenew: true, + cancellationReason: undefined, + }), + active: current + ? entitlementActive({ ...current, willRenew: true }) + : false, + transition: "Uncanceled", + }; + case "SubscriptionRevoked": + return { + next: carryForward({ + state: "Revoked", + willRenew: false, + cancellationReason: "Refunded", + }), + active: false, + transition: "Revoked", + }; + case "PurchaseRefunded": + return { + next: carryForward({ + state: "Refunded", + willRenew: false, + cancellationReason: "Refunded", + }), + active: false, + transition: "Refunded", + }; + case "SubscriptionProductChanged": + return { + next: carryForward({ + // The event itself doesn't include the new productId in its + // typed surface; receivers will overwrite when they have it. + // Until then we keep the old productId but mark Active. + state: "Active", + }), + active: true, + transition: "ProductChanged", + }; + case "SubscriptionPriceChange": + return { + next: carryForward({ + currency: event.currency, + priceAmountMicros: event.priceAmountMicros, + }), + active: current ? entitlementActive(current) : true, + transition: "PriceChanged", + }; + case "SubscriptionPaused": + return { + next: carryForward({ state: "Paused", willRenew: false }), + active: false, + transition: "Paused", + }; + default: + return { + next: current, + active: current ? entitlementActive(current) : false, + transition: "Ignored", + }; + } +} + +// Entitlement rule: status grants access AND the period hasn't expired. +// Matches onesub's `/onesub/status` collapse — see +// packages/server/src/routes/status.ts:46-62 in onesub for the same +// `statusAllows && notYetExpired` pattern. +export function entitlementActive( + sub: NonNullable, + now: number = Date.now(), +): boolean { + if (!ENTITLED_STATES.has(sub.state)) return false; + if (sub.expiresAt != null && sub.expiresAt <= now) return false; + return true; +} diff --git a/packages/kit/convex/subscriptions/stats.ts b/packages/kit/convex/subscriptions/stats.ts new file mode 100644 index 00000000..d4ddb702 --- /dev/null +++ b/packages/kit/convex/subscriptions/stats.ts @@ -0,0 +1,537 @@ +// Incremental subscription stats maintenance — keeps the +// `subscriptionStats` aggregation table in sync as subscriptions +// transition through state machine events. The dashboard's +// `metricsSummary` reads from this table so the headline counters +// stay accurate above the prior SUBS_SCAN_CAP=10,000 bound. + +import type { MutationCtx } from "../_generated/server"; +import { internalMutation } from "../_generated/server"; +import { internal } from "../_generated/api"; +import { v } from "convex/values"; +import type { Doc, Id } from "../_generated/dataModel"; + +import { monthlyMicrosForSub } from "./monthlyMicros"; + +// Counted state buckets. Other states (Expired / Revoked / Refunded / +// Paused / Unknown) don't contribute to the live counters — those are +// either historical archive (no MRR) or surfaced via the rolling +// 30-day window which `metricsSummary` queries directly off the +// `by_project_and_state` index. +const COUNTED_STATES = ["Active", "InGracePeriod", "InBillingRetry"] as const; +type CountedState = (typeof COUNTED_STATES)[number]; + +function isCountedState(state: string): state is CountedState { + return (COUNTED_STATES as readonly string[]).includes(state); +} + +type StatsContribution = { + // Currency the row contributes under. `null` when the row is in a + // counted state but has no priceAmountMicros/currency (e.g. fresh + // sub before the first webhook surfaced pricing) — those bump the + // state counter under the special "" currency bucket so we don't + // lose them entirely. + currency: string; + state: CountedState | null; + mrrMicros: number; +}; + +// Compute what a single subscription row contributes to the stats +// table. Returns null when the row is in a non-counted state — the +// caller should just skip applying any delta. +export function statsContributionFor( + sub: Doc<"subscriptions">, + billingPeriod: string | undefined, + now: number = Date.now(), +): StatsContribution | null { + if (!isCountedState(sub.state)) return null; + // Active subs whose `expiresAt` has passed but webhook hasn't yet + // marked them Expired don't contribute (matches the read-path + // `isActive` semantics). + if ( + sub.state === "Active" && + typeof sub.expiresAt === "number" && + sub.expiresAt <= now + ) { + return null; + } + const currency = sub.currency ?? ""; + const mrrMicros = + sub.state === "Active" && + typeof sub.priceAmountMicros === "number" && + sub.currency + ? monthlyMicrosForSub(sub, billingPeriod) + : 0; + // `isCountedState` already narrowed the union earlier, so sub.state + // is provably one of "Active" | "InGracePeriod" | "InBillingRetry" + // here. + return { currency, state: sub.state, mrrMicros }; +} + +// Apply a (subscription, before, after) state-machine transition to +// the stats table. Both `before` and `after` are nullable: insert = +// `before == null`, delete = `after == null`. Pure DB writes — the +// caller is responsible for fetching the current docs. +// +// The function fetches the relevant `subscriptionStats` row(s) on +// demand. We accept the small extra read cost in exchange for not +// requiring callers to pre-fetch — the alternative (caller passes +// the stats row in) bleeds the aggregation invariant across every +// call site. +export async function applyStatsTransition( + ctx: MutationCtx, + projectId: Id<"projects">, + before: StatsContribution | null, + after: StatsContribution | null, +): Promise { + // No-op when neither side counts. + if (before === null && after === null) return; + // No-op when contribution didn't change. Cheap early-out for the + // common Active-renewal case where state + currency + MRR all stay + // the same. + if ( + before !== null && + after !== null && + before.currency === after.currency && + before.state === after.state && + before.mrrMicros === after.mrrMicros + ) { + return; + } + + const now = Date.now(); + + // Currency-cohort handling: if before/after differ in currency, we + // touch two rows (decrement before, increment after). When they + // match, one row is enough. + if (before && after && before.currency === after.currency) { + await touchStatsRow(ctx, projectId, before.currency, now, (row) => { + const next = applyDelta(row, before, "subtract"); + return applyDelta(next, after, "add"); + }); + return; + } + + if (before) { + await touchStatsRow(ctx, projectId, before.currency, now, (row) => + applyDelta(row, before, "subtract"), + ); + } + if (after) { + await touchStatsRow(ctx, projectId, after.currency, now, (row) => + applyDelta(row, after, "add"), + ); + } +} + +type StatsRowShape = Pick< + Doc<"subscriptionStats">, + "activeSubs" | "inGracePeriod" | "inBillingRetry" | "mrrMicros" +>; + +function applyDelta( + row: StatsRowShape, + contribution: StatsContribution, + op: "add" | "subtract", +): StatsRowShape { + const sign = op === "add" ? 1 : -1; + const next: StatsRowShape = { + activeSubs: row.activeSubs, + inGracePeriod: row.inGracePeriod, + inBillingRetry: row.inBillingRetry, + mrrMicros: row.mrrMicros, + }; + switch (contribution.state) { + case "Active": + next.activeSubs += sign; + next.mrrMicros += sign * contribution.mrrMicros; + break; + case "InGracePeriod": + next.inGracePeriod += sign; + break; + case "InBillingRetry": + next.inBillingRetry += sign; + break; + case null: + // No counted state to apply to — caller shouldn't have called + // us with this contribution, but guard anyway so the function + // is total. + break; + } + // Defensive clamp: stats rows must never go negative. A negative + // count is a sign of a missed event somewhere upstream; clamping + // to zero keeps the dashboard sensible while we surface the + // underlying drift via the `recomputeSubscriptionStats` mutation. + next.activeSubs = Math.max(0, next.activeSubs); + next.inGracePeriod = Math.max(0, next.inGracePeriod); + next.inBillingRetry = Math.max(0, next.inBillingRetry); + if (next.mrrMicros < 0) next.mrrMicros = 0; + return next; +} + +async function touchStatsRow( + ctx: MutationCtx, + projectId: Id<"projects">, + currency: string, + now: number, + mutate: (row: StatsRowShape) => StatsRowShape, +): Promise { + const existing = await ctx.db + .query("subscriptionStats") + .withIndex("by_project_and_currency", (q) => + q.eq("projectId", projectId).eq("currency", currency), + ) + .unique(); + const start: StatsRowShape = existing + ? { + activeSubs: existing.activeSubs, + inGracePeriod: existing.inGracePeriod, + inBillingRetry: existing.inBillingRetry, + mrrMicros: existing.mrrMicros, + } + : { + activeSubs: 0, + inGracePeriod: 0, + inBillingRetry: 0, + mrrMicros: 0, + }; + const next = mutate(start); + if (existing) { + await ctx.db.patch(existing._id, { ...next, updatedAt: now }); + } else { + await ctx.db.insert("subscriptionStats", { + projectId, + currency, + ...next, + updatedAt: now, + }); + } +} + +// Daily drift-correction cron entry point. Picks the most-stale +// projects (one mutation, tiny index scan) and SCHEDULES each +// project's recompute as a separate mutation via the Convex +// scheduler. Per-project mutations get their own 40k document-read +// budget — running them inline would force the picker mutation to +// share its budget with N project recomputes, which exceeds the +// 40k cap once batchSize × per-project-reads > 40k. +// +// Why: the incremental path in `applySubscriptionEvent` / +// `recordHorizonStatus` is correct in steady state, but a missed +// invocation (action timeout, schema drift during rollout, manual +// db.patch) can drift the counters. Running a full recompute daily +// keeps the dashboard self-healing without needing operator +// intervention. +export const recomputeAllSubscriptionStats = internalMutation({ + args: { + // Per-tick cap on how many projects to schedule. Each project + // runs in its own mutation (independent 40k budget), so a higher + // batchSize is safe — but we still default conservatively so a + // deployment with thousands of projects doesn't queue them all + // at once. + batchSize: v.optional(v.number()), + }, + returns: v.object({ scheduled: v.number() }), + handler: async (ctx, args) => { + // Default to 50 projects per daily tick: each runs as its own + // mutation so the picker's budget isn't shared. With cron daily + // cadence + batchSize=50, a deployment with up to 1500 projects + // cycles through every project at least monthly. + const limit = args.batchSize ?? 50; + // Walk the `by_updated_at` index ascending so the most-stale rows + // surface first. Take ~3× the project budget to dedupe by + // projectId (a project has one row per currency and we recompute + // the whole project once per batch slot) without walking past + // `limit` distinct projects' worth of stale data. Capped at + // SCAN_CAP so a corrupted clock skew can't make us scan the + // entire table. + const SCAN_CAP = Math.max(limit * 3, 300); + const stale = await ctx.db + .query("subscriptionStats") + .withIndex("by_updated_at") + .order("asc") + .take(SCAN_CAP); + const seenProjects = new Set(); + const ordered: Id<"projects">[] = []; + for (const row of stale) { + if (seenProjects.has(row.projectId)) continue; + seenProjects.add(row.projectId); + ordered.push(row.projectId); + if (ordered.length >= limit) break; + } + let scheduled = 0; + for (const projectId of ordered) { + // Schedule each per-project recompute as its own mutation so + // the 40k document-read limit is per-project, not summed + // across the batch. `runAfter(0, ...)` queues immediately; + // Convex serializes them on its scheduler. + await ctx.scheduler.runAfter( + 0, + internal.subscriptions.stats.recomputeSubscriptionStats, + { projectId }, + ); + scheduled += 1; + } + return { scheduled }; + }, +}); + +// Hard upper bound on how many subscription rows the recompute walks +// per project. Set conservatively below Convex's hard 40k document- +// Per-page subscription read budget. 5_000 keeps a single page well +// under Convex's 40k document-read mutation budget (with headroom for +// the per-page product + existing-stats reads). Pages chain via +// `ctx.scheduler.runAfter(0, ...)` so a project of ANY size completes +// without ever blowing the per-mutation ceiling — the prior +// "skip-when-exceeds 30k" path is gone (PR #124 +// (https://github.com/hyodotdev/openiap/pull/124) review). +const RECOMPUTE_PAGE_SIZE = 5_000; + +type RecomputeBucket = { + activeSubs: number; + inGracePeriod: number; + inBillingRetry: number; + mrrMicros: number; +}; + +// Convex value form of `Map` so it survives +// scheduler-arg serialization between pages. +const recomputeAccumulator = v.array( + v.object({ + currency: v.string(), + activeSubs: v.number(), + inGracePeriod: v.number(), + inBillingRetry: v.number(), + mrrMicros: v.number(), + }), +); + +async function runRecompute( + ctx: MutationCtx, + projectId: Id<"projects">, +): Promise { + // Kicks off the paginated recompute. The first page processes + // RECOMPUTE_PAGE_SIZE rows and either schedules itself for the + // next page or commits to subscriptionStats when isDone. + // + // `runStartedAt` is the watermark for stale-write detection: the + // final commit aborts if any subscription row has been written + // since this timestamp, because the incremental path's + // `applyStatsTransition` will have already updated subscriptionStats + // for those writes and our paged snapshot would clobber them with + // older counts. + await runRecomputePageInline(ctx, { + projectId, + cursor: null, + accumulator: [], + runStartedAt: Date.now(), + }); +} + +async function runRecomputePageInline( + ctx: MutationCtx, + args: { + projectId: Id<"projects">; + cursor: string | null; + accumulator: Array; + runStartedAt: number; + }, +): Promise { + // Build periodByPlatformProduct from the per-platform product + // index, keyed by `${platform}:${productId}`. The same SKU can + // exist on both stores with different billing periods, so a + // single-key map would have one platform's period overwrite the + // other's and skew MRR (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + // We re-fetch on every page because product catalogs are small + // (typically 10-100 rows per platform per project) and re-reading + // is much cheaper than serializing the map through scheduler args. + const periodByPlatformProduct = new Map(); + for (const platform of ["IOS", "Android"] as const) { + const productRows = await ctx.db + .query("products") + .withIndex("by_project_and_platform", (q) => + q.eq("projectId", args.projectId).eq("platform", platform), + ) + .collect(); + for (const product of productRows) { + periodByPlatformProduct.set( + `${platform}:${product.productId}`, + product.billingPeriod, + ); + } + } + + // Read one page of subscriptions oldest-first via the + // by_project_and_updated index. Order is deterministic so the + // continuation cursor stays valid across mutations. + const result = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_updated", (q) => + q.eq("projectId", args.projectId), + ) + .order("asc") + .paginate({ numItems: RECOMPUTE_PAGE_SIZE, cursor: args.cursor }); + + // Hydrate the carry-in accumulator into a Map for fast lookup. + const buckets = new Map(); + for (const row of args.accumulator) { + buckets.set(row.currency, { + activeSubs: row.activeSubs, + inGracePeriod: row.inGracePeriod, + inBillingRetry: row.inBillingRetry, + mrrMicros: row.mrrMicros, + }); + } + const now = Date.now(); + for (const sub of result.page) { + const contribution = statsContributionFor( + sub, + periodByPlatformProduct.get(`${sub.platform}:${sub.productId}`), + now, + ); + if (!contribution || contribution.state === null) continue; + const bucket = buckets.get(contribution.currency) ?? { + activeSubs: 0, + inGracePeriod: 0, + inBillingRetry: 0, + mrrMicros: 0, + }; + switch (contribution.state) { + case "Active": + bucket.activeSubs += 1; + bucket.mrrMicros += contribution.mrrMicros; + break; + case "InGracePeriod": + bucket.inGracePeriod += 1; + break; + case "InBillingRetry": + bucket.inBillingRetry += 1; + break; + } + buckets.set(contribution.currency, bucket); + } + + if (!result.isDone) { + // Re-serialize buckets and chain the next page in a fresh + // mutation so each page gets its own 40k document-read budget. + const nextAccumulator: Array = []; + for (const [currency, bucket] of buckets) { + nextAccumulator.push({ currency, ...bucket }); + } + await ctx.scheduler.runAfter( + 0, + internal.subscriptions.stats.runRecomputePage, + { + projectId: args.projectId, + cursor: result.continueCursor, + accumulator: nextAccumulator, + runStartedAt: args.runStartedAt, + }, + ); + return; + } + + // Concurrent-write detection. If any subscription row was updated + // since the recompute started, the incremental path + // (applySubscriptionEvent / recordHorizonStatus) has already + // applied that delta to subscriptionStats — our paged snapshot is + // stale and must NOT overwrite it. Abort the commit; the next + // cron tick will pick this project back up. Convex mutations are + // transactional, so this read + the delete/insert below run in a + // single serialized txn — no further race window. + const concurrentWrite = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_updated", (q) => + q.eq("projectId", args.projectId).gt("updatedAt", args.runStartedAt), + ) + .first(); + if (concurrentWrite) { + console.info( + `[subscriptionStats] aborting recompute commit for project=${args.projectId} — subscription updated at ${concurrentWrite.updatedAt} > runStartedAt=${args.runStartedAt}; incremental path is authoritative.`, + ); + return; + } + + // Last page — commit the totals to subscriptionStats. + const existing = await ctx.db + .query("subscriptionStats") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .collect(); + for (const row of existing) { + await ctx.db.delete(row._id); + } + if (buckets.size === 0) { + // Sentinel zero-row so the project still surfaces in the + // `by_updated_at` index for the next cron pick. Without this, a + // project whose subs are all in non-counted states (Expired / + // Refunded / etc.) would have ZERO rows after the delete loop + // above — the picker only discovers projects via + // subscriptionStats, so the project would fall out of drift + // correction permanently. The empty `""` currency bucket is + // ignored by metricsSummary's mrrAccumulators (it only sums + // currencies that have non-zero MRR + non-empty currency code). + await ctx.db.insert("subscriptionStats", { + projectId: args.projectId, + currency: "", + activeSubs: 0, + inGracePeriod: 0, + inBillingRetry: 0, + mrrMicros: 0, + updatedAt: now, + }); + } else { + for (const [currency, bucket] of buckets) { + await ctx.db.insert("subscriptionStats", { + projectId: args.projectId, + currency, + ...bucket, + updatedAt: now, + }); + } + } +} + +// Public mutation entry point for chained pages. Internal cron + the +// kick-off `runRecompute` call into `runRecomputePageInline` directly; +// only the scheduler dispatches into this exported handler. +export const runRecomputePage = internalMutation({ + args: { + projectId: v.id("projects"), + cursor: v.union(v.string(), v.null()), + accumulator: recomputeAccumulator, + runStartedAt: v.number(), + }, + returns: v.null(), + handler: async (ctx, args) => { + await runRecomputePageInline(ctx, args); + return null; + }, +}); + +// Full rebuild of `subscriptionStats` for one project. Walks every +// subscription row and computes the canonical counts from scratch. +// Called from the migration / a manual reconciliation entry point — +// NOT on the read path. Bounded by the project's actual subscription +// count (no SUBS_SCAN_CAP); Convex paginates through the index so a +// 100k-sub project completes in batches of ~5000. +// +// Returns the resulting (currency, counts) tuples for telemetry + +// debugging. +export const recomputeSubscriptionStats = internalMutation({ + args: { + projectId: v.id("projects"), + }, + // null because the recompute is now async-paged: the kick-off + // mutation processes the first page and chains the rest via + // `ctx.scheduler.runAfter` so each page gets its own 40k + // document-read budget. Reading subscriptionStats here would only + // see the first page's contribution for any project > PAGE_SIZE + // (PR #124 (https://github.com/hyodotdev/openiap/pull/124) review). + // Callers that want post-recompute telemetry should query + // subscriptionStats directly after a few seconds. + returns: v.null(), + handler: async (ctx, args) => { + await runRecompute(ctx, args.projectId); + return null; + }, +}); diff --git a/packages/kit/convex/userProfiles/drain-account-deletion.test.ts b/packages/kit/convex/userProfiles/drain-account-deletion.test.ts index 438bb013..dcdb2334 100644 --- a/packages/kit/convex/userProfiles/drain-account-deletion.test.ts +++ b/packages/kit/convex/userProfiles/drain-account-deletion.test.ts @@ -225,7 +225,7 @@ async function drainAccountDeletionBatch( const remaining = await ctx.db .query("organizationMembers") .withIndex("by_organization", (q) => - q.eq("organizationId", membership.organizationId as string), + q.eq("organizationId", membership.organizationId), ) .take(ACCOUNT_DELETION_PAGE); if (remaining.length === 0) { diff --git a/packages/kit/convex/utils/concurrency.ts b/packages/kit/convex/utils/concurrency.ts new file mode 100644 index 00000000..49a7bacb --- /dev/null +++ b/packages/kit/convex/utils/concurrency.ts @@ -0,0 +1,29 @@ +// Bounded-parallelism mapper. Replaces a plain `Promise.all(items.map(fn))` +// pattern with one that limits in-flight calls to `concurrency` — +// useful for fan-outs against external APIs that throttle aggressively +// (App Store Connect, Meta Graph, etc.) where unbounded parallelism +// would trip 429s, but sequential `for await` would balloon wall-clock +// time on large batches. +// +// Output preserves input order regardless of completion order so the +// caller can pair results back to their source items by index. +export async function mapWithConcurrency( + items: ReadonlyArray, + concurrency: number, + fn: (item: T, index: number) => Promise, +): Promise { + const out: R[] = new Array(items.length); + let cursor = 0; + const workers = Array.from( + { length: Math.max(1, Math.min(concurrency, items.length)) }, + async () => { + while (true) { + const idx = cursor++; + if (idx >= items.length) return; + out[idx] = await fn(items[idx], idx); + } + }, + ); + await Promise.all(workers); + return out; +} diff --git a/packages/kit/convex/webhooks/apple.ts b/packages/kit/convex/webhooks/apple.ts new file mode 100644 index 00000000..b42aa695 --- /dev/null +++ b/packages/kit/convex/webhooks/apple.ts @@ -0,0 +1,350 @@ +"use node"; +import { + Environment, + SignedDataVerifier, + type ResponseBodyV2DecodedPayload, + type JWSTransactionDecodedPayload, + type JWSRenewalInfoDecodedPayload, +} from "@apple/app-store-server-library"; +import { ConvexError, v } from "convex/values"; + +import { action } from "../_generated/server"; +import { internal } from "../_generated/api"; +import type { Id } from "../_generated/dataModel"; +import { loadAppleRootCertificates } from "../certificates/apple_root_certificates"; +import { getProjectByApiKey } from "../purchases/shared"; +import { + normalizeAppleAsn, + WebhookNormalizationError, + type AppleAsnPayload, + type AppleDecodedTransaction, + type AppleDecodedRenewalInfo, +} from "./shared"; + +type IngestResult = { + eventId: Id<"webhookEvents">; + type: string; + deduped: boolean; +}; + +// HTTP receiver invoked from `server/api/v1/webhooks.ts`. The Hono +// route forwards Apple's POST body (a JSON envelope `{ signedPayload }`) +// and the project's API key. +// +// The action verifies the signedPayload with `SignedDataVerifier`, +// decodes the embedded transaction + renewal JWS, normalizes everything +// through `normalizeAppleAsn`, then calls the idempotent insert mutation. +// Apple retries the same notificationUUID on transient 5xx — that case +// is collapsed inside `recordWebhookEvent` (returns `deduped: true`) +// and the route still responds 200 so Apple stops retrying. +// +// Naming: follows the openiap iOS suffix convention +// (`knowledge/internal/01-naming-conventions.md`) — iOS-specific +// functions end in `IOS`. Even though "Apple" already implies iOS, +// the convention is mechanical and applies to every iOS-only entry +// point. +export const ingestAppleAsnIOS = action({ + args: { + apiKey: v.string(), + signedPayload: v.string(), + }, + returns: v.object({ + eventId: v.id("webhookEvents"), + type: v.string(), + deduped: v.boolean(), + }), + handler: async (ctx, args): Promise => { + const project = await getProjectByApiKey(ctx, args.apiKey); + + // Setup-status gate. Previously the HTTP layer ran a separate + // `getSetupStatus` query before invoking this action — that meant + // every Apple ASN webhook hit Convex twice. Inlining the check + // here cuts the round-trip; the `mapWebhookError` translator + // recognizes "IOS_NOT_CONFIGURED" and returns 412 with the same + // structured error body the prior pre-check produced. + const iosMissing: string[] = []; + if (!project.iosBundleId) iosMissing.push("iosBundleId"); + if (!project.iosAppAppleId) iosMissing.push("iosAppAppleId"); + if (!project.iosAppStoreIssuerId) iosMissing.push("iosAppStoreIssuerId"); + if (!project.iosAppStoreKeyId) iosMissing.push("iosAppStoreKeyId"); + if (iosMissing.length > 0) { + throw new ConvexError({ + code: "IOS_NOT_CONFIGURED", + message: `Apple ASN v2 received but iOS is not configured for this project. Missing: ${iosMissing.join(", ")}.`, + }); + } + + // Decode without verification to inspect environment + bundleId + // before instantiating the verifier — the verifier requires the + // environment up front. + const previewPayload = previewDecodeNotification(args.signedPayload); + + if ( + project.iosBundleId && + previewPayload.data?.bundleId && + previewPayload.data.bundleId !== project.iosBundleId + ) { + // ConvexError so the Hono layer's `mapWebhookError` translates + // this to a 400, not a 500. A bundle mismatch is a permanent + // configuration error — Apple should NOT retry, and 5xx + // triggers automatic retries from ASN that we don't want. + throw new ConvexError({ + code: "BUNDLE_ID_MISMATCH", + message: `Bundle ID mismatch: notification ${previewPayload.data.bundleId} vs project ${project.iosBundleId}`, + }); + } + + const environment = mapPreviewEnvironment(previewPayload.data?.environment); + const appleRootCAs = loadAppleRootCertificates(); + const verifier = new SignedDataVerifier( + appleRootCAs, + // `enableOnlineChecks: false` keeps webhook latency predictable — + // ASN v2 retries on 5xx, but the same OCSP/CRL hiccup that breaks + // a verifyAndDecodeNotification call would be a permanent + // failure here. We still validate the certificate chain offline. + false, + environment, + project.iosBundleId ?? "", + project.iosAppAppleId, + ); + + let payload: ResponseBodyV2DecodedPayload; + try { + payload = await verifier.verifyAndDecodeNotification(args.signedPayload); + } catch (error) { + console.error("[webhooks/apple] notification verification failed", error); + // ConvexError so the Hono `mapWebhookError` translates to 400 — + // signature failure is a permanent error and a 5xx would trigger + // ASN's automatic retry loop forever. Apple's "do not retry on + // permanent failure" guidance maps cleanly to 4xx status codes. + throw new ConvexError({ + code: "INVALID_SIGNATURE", + message: "Apple ASN v2 signature verification failed", + }); + } + + // Decode transaction + renewal JWS if present. Apple sends them + // signed individually inside the outer payload; verifying them is + // optional for ingestion since the outer signature already attests + // to their integrity. We still parse to extract structured fields. + const transaction = decodeOptionalJws( + payload.data?.signedTransactionInfo, + ); + const renewalInfo = decodeOptionalJws( + payload.data?.signedRenewalInfo, + ); + + let normalized; + try { + normalized = normalizeAppleAsn({ + payload: toAppleAsnPayload(payload), + transaction: toDecodedTransaction(transaction), + renewalInfo: toDecodedRenewalInfo(renewalInfo), + }); + } catch (error) { + if (error instanceof WebhookNormalizationError) { + // Selective handling: only `UnknownEventType` is "Apple ships + // new types ahead of openiap spec" — those we ACK as 200 so + // ASN v2 stops retrying. `MissingNotificationId` and + // `MissingPurchaseToken` mean the payload itself is malformed + // — those must surface as 400 so the operator notices, and + // ACK-ing them silently would lose data. + if (error.code === "UnknownEventType") { + console.warn( + "[webhooks/apple] dropping unsupported notification", + error.code, + error.message, + ); + throw new ConvexError({ + code: "UNSUPPORTED_EVENT", + message: error.message, + }); + } + throw new ConvexError({ + code: error.code, + message: error.message, + }); + } + throw error; + } + + const result = await ctx.runMutation( + internal.webhooks.internal.recordWebhookEvent, + { + projectId: project._id, + source: "apple", + sourceNotificationId: normalized.sourceNotificationId, + event: { + type: normalized.type, + sourceFull: normalized.source, + platform: normalized.platform, + environment: normalized.environment, + purchaseToken: normalized.purchaseToken, + productId: normalized.productId, + subscriptionState: normalized.subscriptionState, + expiresAt: normalized.expiresAt, + renewsAt: normalized.renewsAt, + cancellationReason: normalized.cancellationReason, + currency: normalized.currency, + priceAmountMicros: normalized.priceAmountMicros, + occurredAt: normalized.occurredAt, + rawSignedPayload: args.signedPayload, + }, + }, + ); + + // Always run applySubscriptionEvent — the mutation is idempotent + // against `lastEventId`, so a no-op when the row is already at + // this eventId is cheap. Skipping on dedup looked tidy in + // telemetry but left the subscription stranded if the previous + // attempt recorded the event then crashed before patching the + // subscription row, since every Apple retry would dedup before + // ever reaching the state mutation. + // + // TestNotification is the one exception: it has no transaction + // and therefore no purchaseToken to key a subscription row off + // of. The webhookEvents row above is enough to confirm wiring; + // there's nothing to apply on the subscriptions side. + if (normalized.purchaseToken) { + await ctx.runMutation( + internal.subscriptions.internal.applySubscriptionEvent, + { + projectId: project._id, + eventId: result.eventId, + event: { + type: normalized.type, + productId: normalized.productId, + subscriptionState: normalized.subscriptionState, + expiresAt: normalized.expiresAt, + renewsAt: normalized.renewsAt, + cancellationReason: normalized.cancellationReason, + currency: normalized.currency, + priceAmountMicros: normalized.priceAmountMicros, + platform: normalized.platform, + purchaseToken: normalized.purchaseToken, + }, + }, + ); + } + + return { + eventId: result.eventId, + type: normalized.type, + deduped: result.deduped, + }; + }, +}); + +// Decode JWS payload without signature verification. Used pre-verifier +// to discover the environment so we can instantiate SignedDataVerifier +// with the correct value. +// +// Both failure modes (wrong shape, malformed body) are permanent input +// errors — Apple should NOT retry them, so we throw structured +// ConvexErrors that `mapWebhookError` will translate to 400 instead of +// the generic 500 a plain `Error` would produce. +function previewDecodeNotification(jws: string): { + data?: { environment?: string; bundleId?: string }; +} { + const parts = jws.split("."); + if (parts.length !== 3) { + throw new ConvexError({ + code: "INVALID_SIGNATURE", + message: "Apple notification is not a valid JWS", + }); + } + try { + const decoded = JSON.parse( + Buffer.from(parts[1], "base64url").toString("utf-8"), + ); + return decoded as { data?: { environment?: string; bundleId?: string } }; + } catch { + throw new ConvexError({ + code: "INVALID_SIGNATURE", + message: "Apple notification body is not valid JSON", + }); + } +} + +function mapPreviewEnvironment(value: string | undefined): Environment { + switch (value) { + case "Sandbox": + return Environment.SANDBOX; + case "Xcode": + return Environment.XCODE; + default: + return Environment.PRODUCTION; + } +} + +function decodeOptionalJws(jws: string | null | undefined): T | null { + if (!jws) { + return null; + } + const parts = jws.split("."); + if (parts.length !== 3) { + return null; + } + try { + return JSON.parse( + Buffer.from(parts[1], "base64url").toString("utf-8"), + ) as T; + } catch { + return null; + } +} + +function toAppleAsnPayload( + payload: ResponseBodyV2DecodedPayload, +): AppleAsnPayload { + return { + notificationType: String(payload.notificationType ?? ""), + subtype: payload.subtype ? String(payload.subtype) : null, + notificationUUID: payload.notificationUUID ?? "", + signedDate: payload.signedDate ?? Date.now(), + data: payload.data + ? { + environment: payload.data.environment ?? null, + bundleId: payload.data.bundleId ?? null, + appAppleId: payload.data.appAppleId ?? null, + signedTransactionInfo: payload.data.signedTransactionInfo ?? null, + signedRenewalInfo: payload.data.signedRenewalInfo ?? null, + } + : null, + }; +} + +function toDecodedTransaction( + transaction: JWSTransactionDecodedPayload | null, +): AppleDecodedTransaction | null { + if (!transaction) { + return null; + } + return { + originalTransactionId: transaction.originalTransactionId ?? null, + transactionId: transaction.transactionId ?? null, + productId: transaction.productId ?? null, + expiresDate: transaction.expiresDate ?? null, + revocationReason: transaction.revocationReason ?? null, + currency: transaction.currency ?? null, + price: transaction.price ?? null, + }; +} + +function toDecodedRenewalInfo( + renewalInfo: JWSRenewalInfoDecodedPayload | null, +): AppleDecodedRenewalInfo | null { + if (!renewalInfo) { + return null; + } + return { + autoRenewStatus: renewalInfo.autoRenewStatus ?? null, + autoRenewProductId: renewalInfo.autoRenewProductId ?? null, + expirationIntent: renewalInfo.expirationIntent ?? null, + gracePeriodExpiresDate: renewalInfo.gracePeriodExpiresDate ?? null, + isInBillingRetryPeriod: renewalInfo.isInBillingRetryPeriod ?? null, + renewalDate: renewalInfo.renewalDate ?? null, + recentSubscriptionStartDate: + renewalInfo.recentSubscriptionStartDate ?? null, + }; +} diff --git a/packages/kit/convex/webhooks/conformance.test.ts b/packages/kit/convex/webhooks/conformance.test.ts new file mode 100644 index 00000000..17a75593 --- /dev/null +++ b/packages/kit/convex/webhooks/conformance.test.ts @@ -0,0 +1,349 @@ +// End-to-end conformance harness driving the full webhook → state +// machine → entitlement decision path, using pre-canned ASN v2 + RTDN +// payloads. This is the "sandbox-without-Apple/Google" suite — every +// scenario starts from a deterministic notification payload and +// asserts the resulting `subscriptions` row + entitlement boolean. +// +// The harness exercises: +// 1. `normalizeAppleAsn` / `normalizeGoogleRtdn` (webhook receiver) +// 2. `applySubscriptionTransition` (state machine) +// 3. `entitlementActive` (status route) +// +// Each scenario is a script of `(input event) -> (expected after)` +// transitions so we cover the multi-step lifecycle (purchase → renew +// → cancel → expire, billing-retry → recovery, refund, etc.) rather +// than just a single edge. + +import { describe, expect, it } from "vitest"; + +import { + normalizeAppleAsn, + normalizeGoogleRtdn, + type AppleAsnPayload, + type AppleDecodedTransaction, + type AppleDecodedRenewalInfo, + type GoogleRtdnPayload, + type GoogleSubscriptionInfo, +} from "./shared"; +import { + applySubscriptionTransition, + entitlementActive, + type CurrentSubscription, +} from "../subscriptions/stateMachine"; + +type AppleStep = { + payload: AppleAsnPayload; + transaction?: AppleDecodedTransaction | null; + renewalInfo?: AppleDecodedRenewalInfo | null; + expect: ExpectAfter; +}; + +type GoogleStep = { + payload: GoogleRtdnPayload; + subscriptionInfo?: GoogleSubscriptionInfo | null; + expect: ExpectAfter; +}; + +type ExpectAfter = { + state: NonNullable["state"]; + active: boolean; + willRenew?: boolean; + cancellationReason?: NonNullable["cancellationReason"]; +}; + +function runAppleScenario( + steps: AppleStep[], + productId = "com.example.premium", +) { + let current: CurrentSubscription = null; + for (const [index, step] of steps.entries()) { + const normalized = normalizeAppleAsn({ + payload: step.payload, + transaction: { + originalTransactionId: "txn-1", + productId, + ...(step.transaction ?? {}), + }, + renewalInfo: step.renewalInfo, + }); + const transition = applySubscriptionTransition(current, { + type: normalized.type, + productId: normalized.productId, + subscriptionState: normalized.subscriptionState, + expiresAt: normalized.expiresAt, + renewsAt: normalized.renewsAt, + cancellationReason: normalized.cancellationReason, + currency: normalized.currency, + priceAmountMicros: normalized.priceAmountMicros, + }); + // Fail loudly when the state machine returns no next-state on a step + // that expects forward progress; the prior `transition.next ?? current` + // would silently keep the old `current` and let same-state assertions + // (e.g. Active → Active on DID_RENEW) pass without exercising the + // transition. + expect( + transition.next, + `step ${index} produced no next state`, + ).toBeTruthy(); + current = transition.next ?? current; + expect(current?.state, `step ${index} state`).toBe(step.expect.state); + expect(transition.active, `step ${index} active`).toBe(step.expect.active); + if (step.expect.willRenew !== undefined) { + expect(current?.willRenew, `step ${index} willRenew`).toBe( + step.expect.willRenew, + ); + } + if (step.expect.cancellationReason !== undefined) { + expect( + current?.cancellationReason, + `step ${index} cancellationReason`, + ).toBe(step.expect.cancellationReason); + } + } + return current; +} + +function runGoogleScenario(steps: GoogleStep[], productId = "premium_monthly") { + let current: CurrentSubscription = null; + for (const [index, step] of steps.entries()) { + const normalized = normalizeGoogleRtdn({ + payload: step.payload, + subscriptionInfo: step.subscriptionInfo, + }); + const transition = applySubscriptionTransition(current, { + type: normalized.type, + productId: normalized.productId ?? productId, + subscriptionState: normalized.subscriptionState, + expiresAt: normalized.expiresAt, + renewsAt: normalized.renewsAt, + cancellationReason: normalized.cancellationReason, + currency: normalized.currency, + priceAmountMicros: normalized.priceAmountMicros, + }); + // Fail loudly when the state machine returns no next-state on a step + // that expects forward progress; the prior `transition.next ?? current` + // would silently keep the old `current` and let same-state assertions + // (e.g. Active → Active on DID_RENEW) pass without exercising the + // transition. + expect( + transition.next, + `step ${index} produced no next state`, + ).toBeTruthy(); + current = transition.next ?? current; + expect(current?.state, `google step ${index} state`).toBe( + step.expect.state, + ); + expect(transition.active, `google step ${index} active`).toBe( + step.expect.active, + ); + if (step.expect.willRenew !== undefined) { + expect(current?.willRenew, `google step ${index} willRenew`).toBe( + step.expect.willRenew, + ); + } + if (step.expect.cancellationReason !== undefined) { + expect( + current?.cancellationReason, + `google step ${index} cancellationReason`, + ).toBe(step.expect.cancellationReason); + } + } + return current; +} + +const FUTURE = 9_999_999_999_000; + +describe("conformance: Apple lifecycle scenarios", () => { + it("purchase → renew → cancel → expire", () => { + const final = runAppleScenario([ + { + payload: applePayload("SUBSCRIBED", "INITIAL_BUY", "u-1"), + transaction: { originalTransactionId: "1", expiresDate: FUTURE }, + expect: { state: "Active", active: true, willRenew: true }, + }, + { + payload: applePayload("DID_RENEW", undefined, "u-2"), + transaction: { + originalTransactionId: "1", + expiresDate: FUTURE + 1, + }, + expect: { state: "Active", active: true, willRenew: true }, + }, + { + payload: applePayload( + "DID_CHANGE_RENEWAL_STATUS", + "AUTO_RENEW_DISABLED", + "u-3", + ), + transaction: { originalTransactionId: "1", expiresDate: FUTURE + 1 }, + expect: { + state: "Active", + active: true, + willRenew: false, + cancellationReason: "UserCanceled", + }, + }, + { + payload: applePayload("EXPIRED", undefined, "u-4"), + transaction: { originalTransactionId: "1", expiresDate: 0 }, + renewalInfo: { expirationIntent: 1 }, + expect: { + state: "Expired", + active: false, + willRenew: false, + cancellationReason: "UserCanceled", + }, + }, + ]); + expect(entitlementActive(final!)).toBe(false); + }); + + it("grace-period → recovery", () => { + const final = runAppleScenario([ + { + payload: applePayload("SUBSCRIBED", "INITIAL_BUY", "b-1"), + transaction: { originalTransactionId: "2", expiresDate: FUTURE }, + expect: { state: "Active", active: true }, + }, + { + payload: applePayload("DID_FAIL_TO_RENEW", "GRACE_PERIOD", "b-2"), + transaction: { originalTransactionId: "2", expiresDate: FUTURE }, + expect: { state: "InGracePeriod", active: true }, + }, + { + payload: applePayload("DID_RENEW", "BILLING_RECOVERY", "b-3"), + transaction: { + originalTransactionId: "2", + expiresDate: FUTURE + 100, + }, + expect: { state: "Active", active: true, willRenew: true }, + }, + ]); + expect(entitlementActive(final!)).toBe(true); + }); + + it("refund flow flips state to Refunded and de-entitles", () => { + const final = runAppleScenario([ + { + payload: applePayload("SUBSCRIBED", "INITIAL_BUY", "r-1"), + transaction: { originalTransactionId: "3", expiresDate: FUTURE }, + expect: { state: "Active", active: true }, + }, + { + payload: applePayload("REFUND", undefined, "r-2"), + transaction: { originalTransactionId: "3", expiresDate: FUTURE }, + expect: { + state: "Refunded", + active: false, + cancellationReason: "Refunded", + }, + }, + ]); + // The state machine flagged this user as not entitled — verify the + // entitlement helper agrees instead of trusting it implicitly. + expect(entitlementActive(final!)).toBe(false); + }); +}); + +describe("conformance: Google lifecycle scenarios", () => { + it("purchase → renew → on-hold → recovered", () => { + const final = runGoogleScenario([ + { + payload: googleSubPayload("g-1", 4, "tok-1"), + subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, + expect: { state: "Active", active: true }, + }, + { + payload: googleSubPayload("g-2", 2, "tok-1"), + subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, + expect: { state: "Active", active: true }, + }, + { + payload: googleSubPayload("g-3", 5, "tok-1"), + subscriptionInfo: { state: "SUBSCRIPTION_STATE_ON_HOLD" }, + expect: { state: "InBillingRetry", active: false }, + }, + { + payload: googleSubPayload("g-4", 1, "tok-1"), + subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, + expect: { state: "Active", active: true }, + }, + ]); + expect(entitlementActive(final!)).toBe(true); + }); + + it("voided purchase flips to Refunded", () => { + const final = runGoogleScenario([ + { + payload: googleSubPayload("v-1", 4, "tok-vp"), + subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, + expect: { state: "Active", active: true }, + }, + { + payload: { + messageId: "v-2", + eventTimeMillis: 1, + voidedPurchaseNotification: { purchaseToken: "tok-vp" }, + }, + expect: { state: "Refunded", active: false }, + }, + ]); + expect(entitlementActive(final!)).toBe(false); + }); + + it("paused → resumed", () => { + const final = runGoogleScenario([ + { + payload: googleSubPayload("p-1", 4, "tok-p"), + subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, + expect: { state: "Active", active: true }, + }, + { + payload: googleSubPayload("p-2", 10, "tok-p"), + subscriptionInfo: { state: "SUBSCRIPTION_STATE_PAUSED" }, + expect: { state: "Paused", active: false }, + }, + { + // Resume in real RTDN comes back as RECOVERED (1) — pause- + // schedule-changed (11) is only the schedule update, not the + // actual end-of-pause signal. PR #123 (https://github.com/hyodotdev/openiap/pull/123) review caught the + // earlier draft mapping that treated 11 as Resumed. + payload: googleSubPayload("p-3", 1, "tok-p"), + subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, + expect: { state: "Active", active: true }, + }, + ]); + expect(entitlementActive(final!)).toBe(true); + }); +}); + +function applePayload( + notificationType: string, + subtype: string | undefined, + uuid: string, +): AppleAsnPayload { + return { + notificationType, + subtype, + notificationUUID: uuid, + signedDate: 1_711_000_000_000, + data: { environment: "Production", bundleId: "com.example.app" }, + }; +} + +function googleSubPayload( + messageId: string, + notificationType: number, + purchaseToken: string, +): GoogleRtdnPayload { + return { + messageId, + eventTimeMillis: 1_711_000_000_000, + packageName: "com.example.app", + subscriptionNotification: { + notificationType, + purchaseToken, + subscriptionId: "premium_monthly", + }, + }; +} diff --git a/packages/kit/convex/webhooks/google.ts b/packages/kit/convex/webhooks/google.ts new file mode 100644 index 00000000..878668fa --- /dev/null +++ b/packages/kit/convex/webhooks/google.ts @@ -0,0 +1,508 @@ +"use node"; +import { ConvexError, v } from "convex/values"; +import { google, type androidpublisher_v3 } from "googleapis"; + +import { action } from "../_generated/server"; +import { internal } from "../_generated/api"; +import type { Id } from "../_generated/dataModel"; +import { moneyToMicros } from "../products/play"; +import { getProjectByApiKey } from "../purchases/shared"; +import { + normalizeGoogleRtdn, + WebhookNormalizationError, + type GoogleRtdnPayload, + type GoogleSubscriptionInfo, +} from "./shared"; + +// Module-level cache for the Play Developer API client per project. +// Convex "use node" actions reuse the underlying process for warm +// starts — a fresh service-account fetch + JSON parse + GoogleAuth +// initialization on every webhook adds 50-200ms latency per +// notification and burns Convex storage I/O proportional to traffic. +// Caching the authenticated client survives across consecutive +// webhook invocations on the same machine; cold starts re-build it. +// +// TTL keeps the cache fresh enough that an operator-initiated +// service-account rotation reaches us within an hour without manual +// intervention — credentials don't change often, and a hung-on-old- +// key state would surface as Play API 401s on the affected webhooks +// (which then expire the cache via the catch path below). +const PLAY_CLIENT_TTL_MS = 60 * 60 * 1000; +// Bounded LRU cache. Convex action containers are reused across +// projects, and an unbounded `Map` would grow +// without limit on a multi-tenant deployment — eventually leaking +// memory in the long-running Node process. The cap keeps the cache +// hot for the working set (most webhook traffic concentrates on a +// small subset of high-volume projects) while stopping the long +// tail of one-off projects from accumulating forever (PR #124 +// (https://github.com/hyodotdev/openiap/pull/124) review). +const PLAY_CLIENT_CACHE_MAX_ENTRIES = 100; +const playClientCache = new Map< + string, + { + client: androidpublisher_v3.Androidpublisher; + expiresAt: number; + } +>(); + +// `Map` preserves insertion order, so the first key in iteration is +// the least-recently-set. We re-set on every cache hit (see below) +// to bump the entry to the end, turning the Map into an LRU. +function trimPlayClientCacheLru(): void { + while (playClientCache.size > PLAY_CLIENT_CACHE_MAX_ENTRIES) { + const oldestKey = playClientCache.keys().next().value; + if (oldestKey === undefined) break; + playClientCache.delete(oldestKey); + } +} + +type IngestResult = { + eventId: Id<"webhookEvents">; + type: string; + deduped: boolean; +}; + +// HTTP receiver invoked from `server/api/v1/webhooks.ts` after the +// route layer has verified the Pub/Sub push OIDC token. +// +// The action expects the *parsed* RTDN body — the route is responsible +// for base64-decoding `message.data` and shaping it into our +// GoogleRtdnPayload. From here we optionally enrich with a fetch to +// `androidpublisher.purchases.subscriptionsv2.get` (needs the project's +// service-account JSON) and then call the idempotent insert mutation. +// +// At-least-once Pub/Sub delivery means we'll see duplicate `messageId`s +// on retries; `recordWebhookEvent` collapses those into `deduped: true`. +export const ingestGoogleRtdn = action({ + args: { + apiKey: v.string(), + rawMessage: v.string(), + payload: v.object({ + messageId: v.string(), + packageName: v.optional(v.string()), + eventTimeMillis: v.number(), + subscriptionNotification: v.optional( + v.object({ + notificationType: v.number(), + purchaseToken: v.string(), + subscriptionId: v.string(), + }), + ), + oneTimeProductNotification: v.optional( + v.object({ + notificationType: v.number(), + purchaseToken: v.string(), + sku: v.string(), + }), + ), + voidedPurchaseNotification: v.optional( + v.object({ + purchaseToken: v.string(), + orderId: v.optional(v.string()), + productType: v.optional(v.number()), + refundType: v.optional(v.number()), + }), + ), + testNotification: v.optional(v.object({ version: v.string() })), + }), + }, + returns: v.object({ + eventId: v.id("webhookEvents"), + type: v.string(), + deduped: v.boolean(), + }), + handler: async (ctx, args): Promise => { + const project = await getProjectByApiKey(ctx, args.apiKey); + + // Setup-status gate. Previously the HTTP layer ran a separate + // `getSetupStatus` query before invoking this action; inlining the + // check here cuts the second Convex round-trip per webhook. + // `mapWebhookError` translates "ANDROID_NOT_CONFIGURED" → 412 so + // the operator sees the same structured error the prior pre-check + // produced. + if (!project.androidPackageName) { + throw new ConvexError({ + code: "ANDROID_NOT_CONFIGURED", + message: + "Google RTDN received but Android is not configured for this project. Missing: androidPackageName.", + }); + } + + if ( + project.androidPackageName && + args.payload.packageName && + args.payload.packageName !== project.androidPackageName + ) { + // Permanent input/config mismatch — Pub/Sub will retry forever + // unless we surface this as a 4xx. ConvexError → mapWebhookError + // → 400 so Google stops retrying a notification that can never + // succeed against this project. + throw new ConvexError({ + code: "PACKAGE_NAME_MISMATCH", + message: `Package name mismatch: notification ${args.payload.packageName} vs project ${project.androidPackageName}`, + }); + } + + // Pre-flight idempotency probe: if this messageId is already in + // `webhookIdempotencyKeys`, this is a Pub/Sub redelivery for an + // event we already processed. Short-circuit BEFORE + // maybeFetchSubscriptionInfo so retries don't burn Play Developer + // API quota on every redelivery — kit's webhook receiver becomes a + // multiplier of Play API calls otherwise (one Pub/Sub retry per + // outage minute → one Play API call per retry). The downstream + // recordWebhookEvent + applySubscriptionEvent are still fully + // idempotent, so this is purely a Play-quota / latency optimization. + const preFlightEventId = await ctx.runQuery( + internal.webhooks.internal.lookupExistingEvent, + { + projectId: project._id, + source: "google", + sourceNotificationId: args.payload.messageId, + }, + ); + if (preFlightEventId) { + return { + eventId: preFlightEventId, + type: "WebhookEvent", + deduped: true, + }; + } + + const subscriptionInfo = await maybeFetchSubscriptionInfo( + ctx, + project._id, + project.androidPackageName, + args.payload, + ); + + let normalized; + try { + normalized = normalizeGoogleRtdn({ + payload: args.payload, + subscriptionInfo, + }); + } catch (error) { + if (error instanceof WebhookNormalizationError) { + // Only `UnknownEventType` is "unsupported but well-formed" — + // ACK with a 200-class so Pub/Sub stops re-delivering it (the + // SDK gateway has no use for one-off Google notification kinds + // we don't model). The other two codes + // (`MissingNotificationId`, `MissingPurchaseToken`) indicate a + // malformed payload we genuinely cannot route — surface them + // as ConvexError so `mapWebhookError` translates to 4xx and + // the operator sees the rejection in their pubsub metrics + // instead of having broken events silently swallowed. + if (error.code === "UnknownEventType") { + console.warn( + "[webhooks/google] dropping unsupported notification", + error.code, + error.message, + ); + // Throw a ConvexError so the route layer's `mapWebhookError` + // translates `UNSUPPORTED_EVENT` to a 200 ACK + // (webhooks.ts:788) instead of letting a plain Error 500 the + // Pub/Sub push and trigger Google's exponential retry loop + // on a payload kit will never accept. Matches the Apple + // path's ConvexError shape (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + throw new ConvexError({ + code: "UNSUPPORTED_EVENT", + message: error.message, + }); + } + throw new ConvexError({ code: error.code, message: error.message }); + } + throw error; + } + + const result = await ctx.runMutation( + internal.webhooks.internal.recordWebhookEvent, + { + projectId: project._id, + source: "google", + sourceNotificationId: normalized.sourceNotificationId, + event: { + type: normalized.type, + sourceFull: normalized.source, + platform: normalized.platform, + environment: normalized.environment, + purchaseToken: normalized.purchaseToken, + productId: normalized.productId, + subscriptionState: normalized.subscriptionState, + expiresAt: normalized.expiresAt, + renewsAt: normalized.renewsAt, + cancellationReason: normalized.cancellationReason, + currency: normalized.currency, + priceAmountMicros: normalized.priceAmountMicros, + occurredAt: normalized.occurredAt, + rawSignedPayload: args.rawMessage, + }, + }, + ); + + // Always run applySubscriptionEvent — see the matching note in + // webhooks/apple.ts. The mutation is idempotent on lastEventId so + // a no-op replay is cheap, but skipping on dedup left the + // subscription stranded if a previous attempt persisted the event + // then crashed before patching the subscription row (every Google + // RTDN retry would dedup before reaching the state mutation). + // + // TestNotification is the one exception: it has no transaction + // and therefore no purchaseToken. Skip the subscription mutation + // for those — webhookEvents row alone confirms wiring. + if (normalized.purchaseToken) { + await ctx.runMutation( + internal.subscriptions.internal.applySubscriptionEvent, + { + projectId: project._id, + eventId: result.eventId, + event: { + type: normalized.type, + productId: normalized.productId, + subscriptionState: normalized.subscriptionState, + expiresAt: normalized.expiresAt, + renewsAt: normalized.renewsAt, + cancellationReason: normalized.cancellationReason, + currency: normalized.currency, + priceAmountMicros: normalized.priceAmountMicros, + platform: normalized.platform, + purchaseToken: normalized.purchaseToken, + }, + }, + ); + } + + return { + eventId: result.eventId, + type: normalized.type, + deduped: result.deduped, + }; + }, +}); + +// Best-effort enrichment with subscriptionsv2.get. Returns null when: +// - the project has no Play service account configured (the event +// still flows through with type-derived state), +// - the notification is one-time / voided / test (no subscription to +// look up), +// - or the API call fails. We deliberately swallow the failure rather +// than hard-fail the webhook: kit's authoritative state can be +// reconciled later via `verifyReceipt`. +/** + * `Date.parse` returns NaN for any input it can't parse — and since + * `webhookEvents.expiresAt`/`renewsAt` is typed as `v.number()` in the + * schema, a NaN reaches Convex's validator and 500s the receiver. This + * helper passes only finite numbers through; everything else collapses + * to undefined so the downstream path uses the wall-clock dedup + * heuristic instead. + */ +function parseEpochMs(input: string | undefined | null): number | undefined { + if (!input) return undefined; + const ms = Date.parse(input); + return Number.isFinite(ms) ? ms : undefined; +} + +async function maybeFetchSubscriptionInfo( + ctx: { runAction: any; runQuery: any }, + projectId: unknown, + packageName: string | undefined, + payload: GoogleRtdnPayload, +): Promise { + if (!payload.subscriptionNotification || !packageName) { + return null; + } + + try { + const cacheKey = String(projectId); + let androidpublisher: androidpublisher_v3.Androidpublisher; + const cached = playClientCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + androidpublisher = cached.client; + // LRU bump: re-set the entry so it moves to the end of the + // Map's insertion order. Combined with `trimPlayClientCacheLru` + // below, hot keys stay resident while cold ones get evicted. + playClientCache.delete(cacheKey); + playClientCache.set(cacheKey, cached); + } else { + const serviceAccountFile = await ctx.runQuery( + internal.files.internal.getGooglePlayFileByProjectInternal, + { projectId }, + ); + if (!serviceAccountFile) { + return null; + } + const fileContent = await ctx.runAction( + internal.files.internal.readFileAsText, + { fileId: serviceAccountFile._id }, + ); + if (!fileContent?.content) { + return null; + } + // Wrap the parse in a try/catch and surface a structured + // ConvexError on failure. SyntaxError from a malformed service- + // account upload would otherwise reach the route layer as an + // un-mapped exception and 500 the Pub/Sub push, sending Google + // into a retry loop on a permanent config error. ConvexError + // → mapWebhookError → 400 so Pub/Sub gives up and the operator + // sees the actionable code (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + let credentials: Record; + try { + credentials = JSON.parse(fileContent.content) as Record< + string, + unknown + >; + } catch { + throw new ConvexError({ + code: "INVALID_SERVICE_ACCOUNT_JSON", + message: + "Google Play service account JSON is malformed — re-upload the file generated from Google Cloud Console.", + }); + } + const auth = new google.auth.GoogleAuth({ + credentials, + scopes: ["https://www.googleapis.com/auth/androidpublisher"], + }); + androidpublisher = google.androidpublisher({ version: "v3", auth }); + playClientCache.set(cacheKey, { + client: androidpublisher, + expiresAt: Date.now() + PLAY_CLIENT_TTL_MS, + }); + trimPlayClientCacheLru(); + } + + // Per-request timeout — googleapis defaults to no timeout, and a + // hung Play Developer API call would otherwise stall this Pub/Sub + // ack until Convex's 10-min action ceiling kills the whole + // pipeline. 10s is generous for what's usually a sub-second + // request; missed enrichment is benign (the webhook still + // dedups + flows through applySubscriptionEvent on the raw + // payload — we just lose the v2 expiry/cancel context for this + // notification, and the next event will have it). + const response = await androidpublisher.purchases.subscriptionsv2.get( + { + packageName, + token: payload.subscriptionNotification.purchaseToken, + }, + { timeout: 10_000 }, + ); + + const data = response.data; + // `subscriptionsv2.get` always returns the v2 shape with + // per-line-item `expiryTime`; the legacy `purchases.subscriptions.get` + // had a root-level `expiryTimeMillis`, but we never call that + // endpoint here. + // + // Pick the line item with the longest-dated `expiryTime`. + // Subscriptions V2 supports multi-line-item bundles (base plan + + // add-ons), and just taking `lineItems[0]` would mis-attribute one + // entitlement's expiry / autoRenew to the entire subscription. + // + // We deliberately do NOT match by `latestSuccessfulOrderId`: that + // field carries a GPA Order ID, while the notification carries a + // `purchaseToken` (different identifier — PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). The + // longest-dated line item is the user-relevant "subscription is + // good through" date and matches what the dashboard surfaces. + const lineItems = data.lineItems ?? []; + // `expiryTime` is an ISO string; max-by sorts by Date.parse order. + const matched = + lineItems.reduce<(typeof lineItems)[number] | undefined>((acc, li) => { + if (!li.expiryTime) return acc; + const score = Date.parse(li.expiryTime); + if (!Number.isFinite(score)) return acc; + const accScore = acc?.expiryTime + ? Date.parse(acc.expiryTime) + : -Infinity; + return score > accScore ? li : acc; + }, undefined) ?? lineItems[0]; + const expiry = matched?.expiryTime ?? undefined; + // `autoRenewingPlan` presence is the authoritative v2 indicator + // that auto-renewal is scheduled. Gating `renews` on + // `recurringPrice` (the previous check) misses subscriptions in a + // free-trial phase where the current price is 0 but renewal is + // still on the calendar (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + const renews = matched?.autoRenewingPlan + ? (expiry ?? undefined) + : undefined; + const recurring = matched?.autoRenewingPlan?.recurringPrice; + + return { + state: data.subscriptionState ?? undefined, + cancelReason: data.canceledStateContext?.userInitiatedCancellation + ? "USER_CANCELED" + : data.canceledStateContext?.systemInitiatedCancellation + ? "SYSTEM_INITIATED_CANCELLATION" + : undefined, + // `Date.parse` returns NaN on malformed input, which would + // hit Convex's number validator and 500 the webhook ingest. + // Drop NaN to undefined so the receiver path falls back to the + // wall-clock dedup heuristic (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + expiryTimeMillis: parseEpochMs(expiry), + autoRenewingPlanRenewsTimeMillis: parseEpochMs(renews), + currency: recurring?.currencyCode ?? undefined, + // Use the shared moneyToMicros helper from products/play.ts — + // same Google `Money` proto, same BigInt overflow concerns. + // The shared version also guards against + // `Number.MAX_SAFE_INTEGER` overflow and wraps the BigInt + // parse in try/catch (handles malformed `units` instead of + // throwing into the surrounding subscriptionsv2-get path). + priceAmountMicros: moneyToMicros(recurring), + }; + } catch (error) { + // Re-throw structured ConvexErrors (e.g. INVALID_SERVICE_ACCOUNT_JSON) + // before the generic "transient API failure" fallback below. The + // outer catch is meant to swallow Play Developer API hiccups so + // ingest still completes with type-derived state, but a permanent + // config error shouldn't be silently downgraded to "no + // enrichment" — it needs to surface to the route layer as a 4xx + // so Pub/Sub stops retrying and the operator sees the actionable + // code (PR #124 (https://github.com/hyodotdev/openiap/pull/124) + // review). + if (error instanceof ConvexError) { + throw error; + } + // Sanitized: only the error name/code/message is logged. The full + // googleapis error object can include the original request URL with + // an OAuth bearer token and the response body — neither belongs in + // logs that get shipped to error aggregation. + const sanitized = + error instanceof Error + ? `${error.name}: ${error.message}` + : "(unknown error type)"; + // Auth-shaped failures (401/403, "invalid_grant", "Invalid JWT") + // typically mean the operator rotated the service account. Drop + // the cached client so the next webhook re-reads the file and + // picks up the new credentials immediately instead of waiting + // out the full TTL on a known-bad key. Prefer the structured + // error properties (`code` / `status`) the googleapis library + // ships on its GaxiosError shape — substring matching the + // serialized message also catches the case but is brittle + // (Google has changed wording across SDK versions). The string + // checks stay as a fallback for unwrapped errors. + const errorCode = + typeof error === "object" && error !== null + ? ((error as { code?: unknown }).code ?? + (error as { status?: unknown }).status) + : undefined; + const numericAuthFailure = + errorCode === 401 || + errorCode === 403 || + errorCode === "401" || + errorCode === "403"; + if ( + numericAuthFailure || + sanitized.includes("invalid_grant") || + sanitized.includes("Invalid JWT") + ) { + playClientCache.delete(String(projectId)); + } + console.warn( + "[webhooks/google] subscriptionsv2 fetch failed; falling back to type-derived state", + sanitized, + ); + return null; + } +} diff --git a/packages/kit/convex/webhooks/internal.ts b/packages/kit/convex/webhooks/internal.ts new file mode 100644 index 00000000..61cf17e8 --- /dev/null +++ b/packages/kit/convex/webhooks/internal.ts @@ -0,0 +1,377 @@ +import { internalMutation, internalQuery } from "../_generated/server"; +import { v } from "convex/values"; +import type { Id } from "../_generated/dataModel"; + +// Retention window for `webhookEvents` and `webhookIdempotencyKeys`. +// The same value is referenced by `crons.ts` (which schedules the +// pruner) and by the `webhookEventsSince` SDK reconnect contract +// documented at packages/gql/src/webhook.graphql — clients can +// safely resume from a Last-Event-ID up to this far back, but no +// further. Keep the units literal (ms) so the cron scheduler call +// reads naturally. +export const WEBHOOK_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; + +// Cheap pre-flight dedup probe used by webhooks/google.ts to avoid +// burning Play Developer API quota on Pub/Sub retries. Returns the +// existing eventId if the (projectId, source, sourceNotificationId) +// triple has already been ingested; null otherwise. Distinct from +// `recordWebhookEvent` because it's a query (no DB writes) and runs +// inside the Pub/Sub action's pre-Play-API path so a retry of an +// already-processed messageId can short-circuit before +// `purchases.subscriptionsv2.get` ever fires. +// +// Note: this only checks the new project-keyed index. Legacy rows +// (projectId == null) aren't checked here — they can still slip a +// duplicate Play API call through, but the legacy fallback is rare +// and `recordWebhookEvent` will still dedup the actual event row. +export const lookupExistingEvent = internalQuery({ + args: { + projectId: v.id("projects"), + source: v.union(v.literal("apple"), v.literal("google")), + sourceNotificationId: v.string(), + }, + returns: v.union(v.null(), v.id("webhookEvents")), + handler: async (ctx, args) => { + const existing = await ctx.db + .query("webhookIdempotencyKeys") + .withIndex("by_project_and_source_and_id", (q) => + q + .eq("projectId", args.projectId) + .eq("source", args.source) + .eq("sourceNotificationId", args.sourceNotificationId), + ) + .unique(); + return existing?.eventId ?? null; + }, +}); + +// Insert a normalized webhook event with idempotency on +// `(source, sourceNotificationId)`. Returns the existing event id +// (and `deduped: true`) if Apple/Google retries the same notification. +// +// This is the only path that writes to `webhookEvents` / +// `webhookIdempotencyKeys`. The action layer (apple.ts / google.ts) +// must verify the upstream signature and project ownership before +// calling this — the mutation trusts its arguments. +export const recordWebhookEvent = internalMutation({ + args: { + projectId: v.id("projects"), + source: v.union(v.literal("apple"), v.literal("google")), + sourceNotificationId: v.string(), + event: v.object({ + type: v.union( + v.literal("SubscriptionStarted"), + v.literal("SubscriptionRenewed"), + v.literal("SubscriptionExpired"), + v.literal("SubscriptionInGracePeriod"), + v.literal("SubscriptionInBillingRetry"), + v.literal("SubscriptionRecovered"), + v.literal("SubscriptionCanceled"), + v.literal("SubscriptionUncanceled"), + v.literal("SubscriptionRevoked"), + v.literal("SubscriptionPriceChange"), + v.literal("SubscriptionProductChanged"), + v.literal("SubscriptionPaused"), + v.literal("SubscriptionResumed"), + v.literal("PurchaseRefunded"), + v.literal("PurchaseConsumptionRequest"), + v.literal("TestNotification"), + ), + sourceFull: v.union( + v.literal("AppleAppStoreServerNotificationsV2"), + v.literal("GooglePlayRealTimeDeveloperNotifications"), + v.literal("MetaHorizonReconciler"), + ), + platform: v.union(v.literal("IOS"), v.literal("Android")), + environment: v.union( + v.literal("Production"), + v.literal("Sandbox"), + v.literal("Xcode"), + ), + // Optional because TestNotification payloads carry no transaction. + // Real lifecycle event types always populate this. + purchaseToken: v.optional(v.string()), + productId: v.optional(v.string()), + subscriptionState: v.optional( + v.union( + v.literal("Active"), + v.literal("InGracePeriod"), + v.literal("InBillingRetry"), + v.literal("Expired"), + v.literal("Revoked"), + v.literal("Refunded"), + v.literal("Paused"), + v.literal("Unknown"), + ), + ), + expiresAt: v.optional(v.number()), + renewsAt: v.optional(v.number()), + cancellationReason: v.optional( + v.union( + v.literal("UserCanceled"), + v.literal("BillingError"), + v.literal("PriceIncreaseDeclined"), + v.literal("ProductUnavailable"), + v.literal("Refunded"), + v.literal("Other"), + ), + ), + currency: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + occurredAt: v.number(), + rawSignedPayload: v.optional(v.string()), + }), + }, + returns: v.object({ + eventId: v.id("webhookEvents"), + deduped: v.boolean(), + }), + handler: async (ctx, args) => { + // Dedup check first. Apple ASN may retry the same notificationUUID + // on transient 5xx, and Google Pub/Sub guarantees at-least-once + // delivery — both are normal, both must result in HTTP 200 here. + // + // TODO(schema-cleanup): the `webhookIdempotencyKeys` table is + // arguably redundant with `webhookEvents.by_project_and_notification_id` + // — that index already enforces uniqueness on + // `(projectId, sourceNotificationId)`. We could fold the dedup + // check into a direct lookup against webhookEvents and drop + // this table entirely, halving the per-webhook write + // amplification. Deferred to a separate PR because removing + // the table requires a migration step + careful coordination + // with the existing prune cron. + // Scope dedup by projectId because Google Pub/Sub's messageId is + // only guaranteed unique *within a topic* — different kit + // projects can receive notifications with the same messageId + // and the legacy (source, sourceNotificationId) key would + // cross-pollute them. Apple's notificationUUID is globally + // unique so this is belt-and-braces for ASN, but matching one + // key shape keeps the lookup path simple. + let existing = await ctx.db + .query("webhookIdempotencyKeys") + .withIndex("by_project_and_source_and_id", (q) => + q + .eq("projectId", args.projectId) + .eq("source", args.source) + .eq("sourceNotificationId", args.sourceNotificationId), + ) + .unique(); + // Legacy-row fallback: rows written before the projectId rollout + // don't carry a projectId, so the indexed lookup above misses + // them. Without this fallback, a webhook retry that arrives + // *after* the rollout for an event recorded *before* it would + // bypass dedup and create a fresh webhookEvents row + return a + // new eventId — applySubscriptionEvent would then re-apply a + // transition that's already been committed. We re-query the + // legacy index, confirm the linked event belongs to this + // project, and rehydrate projectId on the row so the next + // lookup hits the new index directly. + if (!existing) { + // Use `.collect()` (not `.unique()`) here. The legacy index is + // `(source, sourceNotificationId)` only, and Google Pub/Sub + // `messageId`s are only unique *within a topic* — so the same + // messageId can appear in legacy rows belonging to different + // projects. `.unique()` would throw on those collisions instead + // of letting us pick the row that matches this project. + const legacyCandidates = await ctx.db + .query("webhookIdempotencyKeys") + .withIndex("by_source_and_id", (q) => + q + .eq("source", args.source) + .eq("sourceNotificationId", args.sourceNotificationId), + ) + .collect(); + // Skip rows already migrated (projectId set) — those would have + // been caught by the `by_project_and_source_and_id` index above. + const legacyOnly = legacyCandidates.filter((row) => !row.projectId); + // Find a legacy row whose linked event belongs to *this* project. + // Walk events in parallel; whichever links to args.projectId is + // ours. Half-written rows (no eventId) are kept as a fallback to + // adopt below if no project-matched row exists. + const linkedChecks = await Promise.all( + legacyOnly.map(async (row) => + row.eventId + ? { + row, + linked: await ctx.db.get(row.eventId), + } + : { row, linked: null }, + ), + ); + const projectMatch = linkedChecks.find( + (c) => c.linked && c.linked.projectId === args.projectId, + ); + if (projectMatch) { + await ctx.db.patch(projectMatch.row._id, { + projectId: args.projectId, + }); + existing = { ...projectMatch.row, projectId: args.projectId }; + } else { + const halfWritten = linkedChecks.find( + (c) => !c.row.eventId && !c.linked, + ); + if (halfWritten) { + // Half-written legacy row (insert succeeded, event insert + // crashed): can't tie it to a project, but adopting it lets + // the path below patch in our new eventId. + existing = halfWritten.row; + } + } + } + + if (existing?.eventId) { + return { eventId: existing.eventId, deduped: true }; + } + + const now = Date.now(); + + const eventId: Id<"webhookEvents"> = await ctx.db.insert("webhookEvents", { + projectId: args.projectId, + type: args.event.type, + source: args.event.sourceFull, + platform: args.event.platform, + environment: args.event.environment, + purchaseToken: args.event.purchaseToken, + sourceNotificationId: args.sourceNotificationId, + productId: args.event.productId, + subscriptionState: args.event.subscriptionState, + expiresAt: args.event.expiresAt, + renewsAt: args.event.renewsAt, + cancellationReason: args.event.cancellationReason, + currency: args.event.currency, + priceAmountMicros: args.event.priceAmountMicros, + rawSignedPayload: args.event.rawSignedPayload, + occurredAt: args.event.occurredAt, + receivedAt: now, + }); + + if (existing) { + // Idempotency key existed without an eventId (a previous attempt + // crashed between dedup-row insert and event insert). Patch it + // to point at the newly-inserted event so future replays dedup. + await ctx.db.patch(existing._id, { eventId }); + } else { + await ctx.db.insert("webhookIdempotencyKeys", { + projectId: args.projectId, + source: args.source, + sourceNotificationId: args.sourceNotificationId, + eventId, + firstSeenAt: now, + }); + } + + return { eventId, deduped: false }; + }, +}); + +// Prune events older than the configured retention window. Run on a +// daily cron — `crons.ts` registers the schedule. +export const pruneWebhookEvents = internalMutation({ + args: { + olderThanMs: v.number(), + batchSize: v.optional(v.number()), + }, + returns: v.object({ deletedEvents: v.number(), deletedKeys: v.number() }), + handler: async (ctx, args) => { + const cutoff = Date.now() - args.olderThanMs; + const limit = args.batchSize ?? 200; + + const oldEvents = await ctx.db + .query("webhookEvents") + .withIndex("by_received_at", (q) => q.lt("receivedAt", cutoff)) + .take(limit); + + // Resolve every matching idempotency key in parallel before + // touching the DB writer. The previous loop did one .unique() + // per event sequentially, so a 500-row prune required 500 RTTs. + // Promise.all here issues them in a single flight — Convex + // serializes them internally on the storage layer but the + // round-trip cost collapses. + // + // Two flavors per event: + // 1. project-keyed lookup via the `by_project_and_source_and_id` + // index — covers every row written after the projectId rollout. + // 2. legacy fallback via `by_source_and_id` — pre-rollout rows + // that point at this event but have `projectId == null`. We + // can't query them through index 1, and the orphan sweep + // below skips rows with a non-null `eventId`, so without + // this they survive past the advertised retention window. + const keysToDelete = await Promise.all( + oldEvents.map(async (event) => { + const source: "apple" | "google" = + event.source === "AppleAppStoreServerNotificationsV2" + ? "apple" + : "google"; + const [keyed, legacyCandidates] = await Promise.all([ + ctx.db + .query("webhookIdempotencyKeys") + .withIndex("by_project_and_source_and_id", (q) => + q + .eq("projectId", event.projectId) + .eq("source", source) + .eq("sourceNotificationId", event.sourceNotificationId), + ) + .unique(), + ctx.db + .query("webhookIdempotencyKeys") + .withIndex("by_source_and_id", (q) => + q + .eq("source", source) + .eq("sourceNotificationId", event.sourceNotificationId), + ) + .collect(), + ]); + // Filter legacy candidates to only the rows that (a) lack a + // projectId (otherwise they'd already be the indexed match) + // and (b) point at *this* event id — preventing accidental + // collateral damage from cross-project messageId collisions + // in the legacy table. + const legacy = legacyCandidates.filter( + (row) => !row.projectId && row.eventId === event._id, + ); + return [keyed, ...legacy].filter( + (row): row is NonNullable => row != null, + ); + }), + ); + + let deletedEvents = 0; + let deletedKeys = 0; + const seenKeyIds = new Set(); + for (let i = 0; i < oldEvents.length; i++) { + for (const key of keysToDelete[i]) { + // Dedup across the project-keyed + legacy paths in case both + // returned the same row (defense — they shouldn't overlap). + if (seenKeyIds.has(key._id)) continue; + seenKeyIds.add(key._id); + // Drop the matching idempotency row. Without this, a stale + // dedup record could outlive its event and silently swallow + // a future (legitimately new) notification that reuses the + // UUID — very unlikely in practice, but the invariant is + // cheap to keep. + await ctx.db.delete(key._id); + deletedKeys += 1; + } + await ctx.db.delete(oldEvents[i]._id); + deletedEvents += 1; + } + + // Also sweep orphan idempotency keys older than the cutoff — + // half-written rows from prior crashes (key insert succeeded, + // event insert failed) where eventId stayed null and the + // by-event lookup above can never reach them. Uses the + // `by_first_seen_at` range index so the scan stays bounded by + // `limit` instead of full-scanning the table as it grows. + const orphanKeys = await ctx.db + .query("webhookIdempotencyKeys") + .withIndex("by_first_seen_at", (q) => q.lt("firstSeenAt", cutoff)) + .take(limit); + for (const key of orphanKeys) { + if (key.eventId) continue; // event-linked keys are handled above + await ctx.db.delete(key._id); + deletedKeys += 1; + } + + return { deletedEvents, deletedKeys }; + }, +}); diff --git a/packages/kit/convex/webhooks/query.ts b/packages/kit/convex/webhooks/query.ts new file mode 100644 index 00000000..2b6be531 --- /dev/null +++ b/packages/kit/convex/webhooks/query.ts @@ -0,0 +1,245 @@ +import { query } from "../_generated/server"; +import type { Doc } from "../_generated/dataModel"; +import { v } from "convex/values"; + +import { + webhookEventTypeValidator, + webhookEventSourceValidator, + webhookEventEnvironmentValidator, + subscriptionStateValidator, + webhookCancellationReasonValidator, + webhookEventPlatformValidator, +} from "./validators"; + +// Stream cursor lookup. Resolves a stable `sourceNotificationId` +// (ASN v2 notificationUUID or RTDN messageId) into a `receivedAt` +// timestamp + Convex `_creationTime` so the SSE reconnect path can +// resume right after the last event the consumer acknowledged. +// +// Surfaces both `receivedAt` and `_creationTime` because two events +// can share `receivedAt` under burst writes — the SSE consumer needs +// the creationTime tie-break to avoid re-emitting the boundary event. +// +// Uses the dedicated `by_project_and_notification_id` index so the +// lookup is O(log n) regardless of how many webhook events the +// project has accumulated. The prior implementation scanned the +// first 500 events via `webhookEventsSince(sinceMs: 0, limit: 500)` +// and silently fell back to "now" for any project with > 500 events. +export const findEventCursor = query({ + args: { + apiKey: v.string(), + sourceNotificationId: v.string(), + }, + returns: v.union( + v.null(), + v.object({ + receivedAt: v.number(), + _creationTime: v.number(), + }), + ), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + if (!project) return null; + + const event = await ctx.db + .query("webhookEvents") + .withIndex("by_project_and_notification_id", (q) => + q + .eq("projectId", project._id) + .eq("sourceNotificationId", args.sourceNotificationId), + ) + .first(); + if (!event) return null; + return { + receivedAt: event.receivedAt, + _creationTime: event._creationTime, + }; + }, +}); + +const webhookEventStreamShape = v.object({ + id: v.string(), + // Convex auto-assigned `_creationTime` (epoch ms, monotonic per + // doc insert). Surfaced so SDKs can checkpoint reliably even + // when two events share the same `receivedAt` — the wall-clock + // tie-breaker is not unique under burst writes (PR #124 (https://github.com/hyodotdev/openiap/pull/124) review + // fix). The Convex doc id (`_id`) is also surfaced for the same + // reason; `id` (sourceNotificationId) stays the spec-stable + // identifier consumers gate on. + _creationTime: v.number(), + _id: v.id("webhookEvents"), + type: webhookEventTypeValidator, + source: webhookEventSourceValidator, + platform: webhookEventPlatformValidator, + environment: webhookEventEnvironmentValidator, + projectId: v.id("projects"), + occurredAt: v.number(), + receivedAt: v.number(), + // Optional because TestNotification rows carry no transaction. + purchaseToken: v.optional(v.string()), + productId: v.optional(v.string()), + subscriptionState: v.optional(subscriptionStateValidator), + expiresAt: v.optional(v.number()), + renewsAt: v.optional(v.number()), + cancellationReason: v.optional(webhookCancellationReasonValidator), + currency: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + rawSignedPayload: v.optional(v.string()), +}); + +function shapeWebhookEvent(event: Doc<"webhookEvents">) { + return { + // GraphQL `id` is the stable per-notification identifier from + // the store; ASN v2 notificationUUID and RTDN messageId are both + // globally unique and survive replay/dedup. + id: event.sourceNotificationId, + _creationTime: event._creationTime, + _id: event._id, + type: event.type, + source: event.source, + platform: event.platform, + environment: event.environment, + projectId: event.projectId, + occurredAt: event.occurredAt, + receivedAt: event.receivedAt, + purchaseToken: event.purchaseToken, + productId: event.productId, + subscriptionState: event.subscriptionState, + expiresAt: event.expiresAt, + renewsAt: event.renewsAt, + cancellationReason: event.cancellationReason, + currency: event.currency, + priceAmountMicros: event.priceAmountMicros, + rawSignedPayload: event.rawSignedPayload, + }; +} + +// Backfill query used by SDKs on reconnect / app foreground entry. +// Returns webhook events for the API key's project that occurred since +// the given timestamp, ordered ascending by `receivedAt` so consumers +// can apply them in order without re-sorting. +// +// We cap results at `limit` (default 100, max 500) and surface +// `_creationTime` so the SDK can checkpoint reliably even if two +// events share `receivedAt` (rare but possible under burst writes). +// +// Optional `afterCreationTime`: when provided alongside `sinceMs`, we +// only emit events whose `_creationTime` is strictly greater than +// the cursor — the tie-break that lets pagination advance past a +// `receivedAt` cohort larger than `limit`. Without it, a burst of +// 500+ events sharing one `receivedAt` would stick the cursor at +// the same value forever (PR #124 (https://github.com/hyodotdev/openiap/pull/124) review fix). +export const webhookEventsSince = query({ + args: { + apiKey: v.string(), + sinceMs: v.number(), + afterCreationTime: v.optional(v.number()), + limit: v.optional(v.number()), + }, + returns: v.array(webhookEventStreamShape), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + + if (!project) { + // Mirror the convention used by other v1 routes: return empty + // rather than throwing on bad credentials so the route layer can + // attach 401 semantics uniformly via apiKeyMiddleware. + return []; + } + + const limit = Math.min(Math.max(args.limit ?? 100, 1), 500); + + // Two-phase walk on the `(projectId, receivedAt, _creationTime)` + // composite index so we never need an in-memory boundary filter + // — which would silently drop pages when a single-millisecond + // burst exceeded the in-memory cap. Phase 1 (only when + // `afterCreationTime` is set) exhausts the boundary-millisecond + // tail past the cursor via `gt("_creationTime", ...)`; Phase 2 + // walks the post-boundary range via `gt("receivedAt", ...)`. The + // SSE layer in webhooks.ts handles the inverse case (consumer + // catching up across many millisecond cohorts) by looping with + // the tuple cursor (PR #124 + // (https://github.com/hyodotdev/openiap/pull/124) review). + let events: Array> = []; + if (args.afterCreationTime !== undefined) { + const boundaryTail = await ctx.db + .query("webhookEvents") + .withIndex("by_project_and_received_and_creation", (q) => + q + .eq("projectId", project._id) + .eq("receivedAt", args.sinceMs) + .gt("_creationTime", args.afterCreationTime!), + ) + .order("asc") + .take(limit); + events.push(...boundaryTail); + } + if (events.length < limit) { + const postBoundary = await ctx.db + .query("webhookEvents") + .withIndex("by_project_and_received_and_creation", (q) => + q.eq("projectId", project._id).gt("receivedAt", args.sinceMs), + ) + .order("asc") + .take(limit - events.length); + events.push(...postBoundary); + } + if (args.afterCreationTime === undefined) { + // `afterCreationTime` not in play: include the boundary cohort. + // The post-boundary scan above used `gt(receivedAt, sinceMs)` + // which excludes it, so prepend the `eq(receivedAt, sinceMs)` + // matches up to the limit. + const boundary = await ctx.db + .query("webhookEvents") + .withIndex("by_project_and_received_and_creation", (q) => + q.eq("projectId", project._id).eq("receivedAt", args.sinceMs), + ) + .order("asc") + .take(limit); + events = [...boundary, ...events].slice(0, limit); + } + + return events.map(shapeWebhookEvent); + }, +}); + +// Reactive wake-up query for the SSE live tail. Unlike +// `webhookEventsSince`, this returns the latest matching window so the +// Convex subscription result keeps changing as new rows arrive. The +// route still drains through `webhookEventsSince` with its own moving +// cursor; this query only tells the route that there is fresh work. +export const latestWebhookEventsSince = query({ + args: { + apiKey: v.string(), + sinceMs: v.number(), + limit: v.optional(v.number()), + }, + returns: v.array(webhookEventStreamShape), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + + if (!project) { + return []; + } + + const limit = Math.min(Math.max(args.limit ?? 100, 1), 500); + const latest = await ctx.db + .query("webhookEvents") + .withIndex("by_project_and_received_and_creation", (q) => + q.eq("projectId", project._id).gte("receivedAt", args.sinceMs), + ) + .order("desc") + .take(limit); + + return latest.reverse().map(shapeWebhookEvent); + }, +}); diff --git a/packages/kit/convex/webhooks/shared.test.ts b/packages/kit/convex/webhooks/shared.test.ts new file mode 100644 index 00000000..c9373273 --- /dev/null +++ b/packages/kit/convex/webhooks/shared.test.ts @@ -0,0 +1,507 @@ +import { describe, expect, it } from "vitest"; +import { + mapAppleNotificationType, + mapGoogleSubscriptionNotificationType, + mapGoogleOneTimeNotificationType, + normalizeAppleAsn, + normalizeGoogleRtdn, + WebhookNormalizationError, + type AppleAsnPayload, + type AppleDecodedTransaction, + type GoogleRtdnPayload, + type GoogleSubscriptionInfo, +} from "./shared"; + +// --------------------------------------------------------------------------- +// Apple ASN v2 mapping +// --------------------------------------------------------------------------- + +describe("mapAppleNotificationType", () => { + it("maps SUBSCRIBED with INITIAL_BUY / RESUBSCRIBE / no subtype to Started", () => { + expect(mapAppleNotificationType("SUBSCRIBED", "INITIAL_BUY")).toBe( + "SubscriptionStarted", + ); + expect(mapAppleNotificationType("SUBSCRIBED", "RESUBSCRIBE")).toBe( + "SubscriptionStarted", + ); + expect(mapAppleNotificationType("SUBSCRIBED")).toBe("SubscriptionStarted"); + }); + + it("distinguishes DID_RENEW (Renewed) from BILLING_RECOVERY (Recovered)", () => { + expect(mapAppleNotificationType("DID_RENEW")).toBe("SubscriptionRenewed"); + expect(mapAppleNotificationType("DID_RENEW", "BILLING_RECOVERY")).toBe( + "SubscriptionRecovered", + ); + }); + + it("distinguishes DID_FAIL_TO_RENEW grace vs billing-retry", () => { + expect(mapAppleNotificationType("DID_FAIL_TO_RENEW", "GRACE_PERIOD")).toBe( + "SubscriptionInGracePeriod", + ); + expect(mapAppleNotificationType("DID_FAIL_TO_RENEW")).toBe( + "SubscriptionInBillingRetry", + ); + }); + + it("maps GRACE_PERIOD_EXPIRED to InBillingRetry", () => { + expect(mapAppleNotificationType("GRACE_PERIOD_EXPIRED")).toBe( + "SubscriptionInBillingRetry", + ); + }); + + it("maps DID_CHANGE_RENEWAL_STATUS by subtype", () => { + expect( + mapAppleNotificationType( + "DID_CHANGE_RENEWAL_STATUS", + "AUTO_RENEW_DISABLED", + ), + ).toBe("SubscriptionCanceled"); + expect( + mapAppleNotificationType( + "DID_CHANGE_RENEWAL_STATUS", + "AUTO_RENEW_ENABLED", + ), + ).toBe("SubscriptionUncanceled"); + // No subtype -> ambiguous, returns null so receiver can drop it + expect(mapAppleNotificationType("DID_CHANGE_RENEWAL_STATUS")).toBeNull(); + }); + + it("maps the remaining single-shot notification types", () => { + expect(mapAppleNotificationType("EXPIRED")).toBe("SubscriptionExpired"); + expect(mapAppleNotificationType("DID_CHANGE_RENEWAL_PREF")).toBe( + "SubscriptionProductChanged", + ); + expect(mapAppleNotificationType("PRICE_INCREASE")).toBe( + "SubscriptionPriceChange", + ); + expect(mapAppleNotificationType("REVOKE")).toBe("SubscriptionRevoked"); + expect(mapAppleNotificationType("REFUND")).toBe("PurchaseRefunded"); + expect(mapAppleNotificationType("REFUND_REVERSED")).toBe( + "SubscriptionStarted", + ); + expect(mapAppleNotificationType("CONSUMPTION_REQUEST")).toBe( + "PurchaseConsumptionRequest", + ); + expect(mapAppleNotificationType("TEST")).toBe("TestNotification"); + }); + + it("returns null for notification types not in the openiap spec yet", () => { + expect(mapAppleNotificationType("OFFER_REDEEMED")).toBeNull(); + expect(mapAppleNotificationType("RENEWAL_EXTENDED")).toBeNull(); + expect(mapAppleNotificationType("EXTERNAL_PURCHASE_TOKEN")).toBeNull(); + expect(mapAppleNotificationType("SOMETHING_NEW_FROM_APPLE")).toBeNull(); + }); +}); + +const baseApplePayload: AppleAsnPayload = { + notificationType: "DID_RENEW", + notificationUUID: "uuid-renew-1", + signedDate: 1_711_000_000_000, + data: { environment: "Production" }, +}; + +const baseTransaction: AppleDecodedTransaction = { + originalTransactionId: "1000000000000001", + transactionId: "1000000000000099", + productId: "com.example.premium_monthly", + expiresDate: 1_713_592_000_000, + // ASN reports `price` in milliunits (1/1000 of a currency unit — + // $9.99 → 9990). Earlier draft mistakenly called these "millicents" + // and applied a 10× multiplier, which #123 review correctly flagged. + price: 9_990, + currency: "USD", +}; + +describe("normalizeAppleAsn", () => { + it("normalizes a vanilla DID_RENEW into SubscriptionRenewed with active state", () => { + const event = normalizeAppleAsn({ + payload: baseApplePayload, + transaction: baseTransaction, + renewalInfo: { renewalDate: 1_713_592_000_000 }, + }); + + expect(event.type).toBe("SubscriptionRenewed"); + expect(event.source).toBe("AppleAppStoreServerNotificationsV2"); + expect(event.platform).toBe("IOS"); + expect(event.environment).toBe("Production"); + expect(event.purchaseToken).toBe("1000000000000001"); + expect(event.productId).toBe("com.example.premium_monthly"); + expect(event.subscriptionState).toBe("Active"); + expect(event.expiresAt).toBe(1_713_592_000_000); + expect(event.renewsAt).toBe(1_713_592_000_000); + expect(event.currency).toBe("USD"); + // 9_990 milliunits × 1000 = 9_990_000 micros ($9.99) + expect(event.priceAmountMicros).toBe(9_990_000); + expect(event.occurredAt).toBe(1_711_000_000_000); + expect(event.sourceNotificationId).toBe("uuid-renew-1"); + }); + + it("derives Sandbox/Xcode environments and falls back to Production on missing data", () => { + const sandbox = normalizeAppleAsn({ + payload: { ...baseApplePayload, data: { environment: "Sandbox" } }, + transaction: baseTransaction, + }); + expect(sandbox.environment).toBe("Sandbox"); + + const xcode = normalizeAppleAsn({ + payload: { ...baseApplePayload, data: { environment: "Xcode" } }, + transaction: baseTransaction, + }); + expect(xcode.environment).toBe("Xcode"); + + const missing = normalizeAppleAsn({ + payload: { ...baseApplePayload, data: null }, + transaction: baseTransaction, + }); + expect(missing.environment).toBe("Production"); + }); + + it("maps AUTO_RENEW_DISABLED into Canceled while keeping state Active until expiry", () => { + const event = normalizeAppleAsn({ + payload: { + ...baseApplePayload, + notificationType: "DID_CHANGE_RENEWAL_STATUS", + subtype: "AUTO_RENEW_DISABLED", + }, + transaction: baseTransaction, + }); + expect(event.type).toBe("SubscriptionCanceled"); + expect(event.subscriptionState).toBe("Active"); + expect(event.cancellationReason).toBe("UserCanceled"); + }); + + it("translates Apple expirationIntent codes 1..5 to cancellation reasons", () => { + const cases: Array<[number, string]> = [ + [1, "UserCanceled"], + [2, "BillingError"], + [3, "PriceIncreaseDeclined"], + [4, "ProductUnavailable"], + [5, "Other"], + ]; + for (const [intent, reason] of cases) { + const event = normalizeAppleAsn({ + payload: { ...baseApplePayload, notificationType: "EXPIRED" }, + transaction: baseTransaction, + renewalInfo: { expirationIntent: intent }, + }); + expect(event.type).toBe("SubscriptionExpired"); + expect(event.cancellationReason).toBe(reason); + } + }); + + it("flags REVOKE / REFUND with cancellationReason = Refunded", () => { + const revoke = normalizeAppleAsn({ + payload: { ...baseApplePayload, notificationType: "REVOKE" }, + transaction: baseTransaction, + }); + expect(revoke.type).toBe("SubscriptionRevoked"); + expect(revoke.cancellationReason).toBe("Refunded"); + + const refund = normalizeAppleAsn({ + payload: { ...baseApplePayload, notificationType: "REFUND" }, + transaction: baseTransaction, + }); + expect(refund.type).toBe("PurchaseRefunded"); + expect(refund.cancellationReason).toBe("Refunded"); + expect(refund.subscriptionState).toBe("Refunded"); + }); + + it("accepts a TEST notification with no transaction/renewal data", () => { + const event = normalizeAppleAsn({ + payload: { + notificationType: "TEST", + notificationUUID: "test-uuid-1", + signedDate: 1_711_000_000_000, + data: { environment: "Sandbox" }, + }, + }); + expect(event.type).toBe("TestNotification"); + // TestNotification carries no transaction → no purchaseToken. + expect(event.purchaseToken).toBeUndefined(); + expect(event.environment).toBe("Sandbox"); + // Test notifications have no subscription state in the spec + expect(event.subscriptionState).toBeUndefined(); + }); + + it("rejects unsupported notification types", () => { + expect(() => + normalizeAppleAsn({ + payload: { ...baseApplePayload, notificationType: "OFFER_REDEEMED" }, + transaction: baseTransaction, + }), + ).toThrow(WebhookNormalizationError); + }); + + it("rejects payloads missing notificationUUID", () => { + expect(() => + normalizeAppleAsn({ + payload: { + ...baseApplePayload, + notificationUUID: "", + }, + transaction: baseTransaction, + }), + ).toThrow(/notificationUUID/); + }); + + it("rejects non-test payloads missing originalTransactionId", () => { + expect(() => + normalizeAppleAsn({ + payload: baseApplePayload, + transaction: { productId: "x" }, + }), + ).toThrow(/originalTransactionId/); + }); +}); + +// --------------------------------------------------------------------------- +// Google RTDN mapping +// --------------------------------------------------------------------------- + +describe("mapGoogleSubscriptionNotificationType", () => { + it("maps the documented numeric codes to spec event types", () => { + // RTDN code reference: + // https://developer.android.com/google/play/billing/rtdn-reference#sub + // Codes 1 / 4 were swapped in an earlier draft (caught in PR #123 (https://github.com/hyodotdev/openiap/pull/123) + // review). 1 = RECOVERED, 4 = PURCHASED. 7 = RESTARTED maps to + // Uncanceled (auto-renew re-enabled), not Started. + expect(mapGoogleSubscriptionNotificationType(1)).toBe( + "SubscriptionRecovered", + ); + expect(mapGoogleSubscriptionNotificationType(2)).toBe( + "SubscriptionRenewed", + ); + expect(mapGoogleSubscriptionNotificationType(3)).toBe( + "SubscriptionCanceled", + ); + expect(mapGoogleSubscriptionNotificationType(4)).toBe( + "SubscriptionStarted", + ); + expect(mapGoogleSubscriptionNotificationType(7)).toBe( + "SubscriptionUncanceled", + ); + expect(mapGoogleSubscriptionNotificationType(5)).toBe( + "SubscriptionInBillingRetry", + ); + expect(mapGoogleSubscriptionNotificationType(6)).toBe( + "SubscriptionInGracePeriod", + ); + expect(mapGoogleSubscriptionNotificationType(8)).toBe( + "SubscriptionPriceChange", + ); + expect(mapGoogleSubscriptionNotificationType(9)).toBe( + "SubscriptionProductChanged", + ); + expect(mapGoogleSubscriptionNotificationType(10)).toBe( + "SubscriptionPaused", + ); + expect(mapGoogleSubscriptionNotificationType(12)).toBe( + "SubscriptionRevoked", + ); + expect(mapGoogleSubscriptionNotificationType(13)).toBe( + "SubscriptionExpired", + ); + }); + + it("returns null for unknown codes", () => { + expect(mapGoogleSubscriptionNotificationType(999)).toBeNull(); + }); +}); + +describe("mapGoogleOneTimeNotificationType", () => { + it("maps purchased and canceled to spec types", () => { + expect(mapGoogleOneTimeNotificationType(1)).toBe("SubscriptionStarted"); + expect(mapGoogleOneTimeNotificationType(2)).toBe("PurchaseRefunded"); + }); +}); + +const baseRtdnSubscription: GoogleRtdnPayload = { + messageId: "rtdn-msg-1", + packageName: "com.example.app", + eventTimeMillis: 1_711_000_000_000, + subscriptionNotification: { + notificationType: 2, + purchaseToken: "play-token-abc", + subscriptionId: "premium_monthly", + }, +}; + +describe("normalizeGoogleRtdn", () => { + it("normalizes SUBSCRIPTION_RENEWED with active state from subscriptionsv2 fetch", () => { + const info: GoogleSubscriptionInfo = { + state: "SUBSCRIPTION_STATE_ACTIVE", + expiryTimeMillis: 1_713_592_000_000, + autoRenewingPlanRenewsTimeMillis: 1_713_592_000_000, + currency: "KRW", + priceAmountMicros: 12_900_000_000, + }; + const event = normalizeGoogleRtdn({ + payload: baseRtdnSubscription, + subscriptionInfo: info, + }); + + expect(event.type).toBe("SubscriptionRenewed"); + expect(event.source).toBe("GooglePlayRealTimeDeveloperNotifications"); + expect(event.platform).toBe("Android"); + expect(event.environment).toBe("Production"); + expect(event.purchaseToken).toBe("play-token-abc"); + expect(event.productId).toBe("premium_monthly"); + expect(event.subscriptionState).toBe("Active"); + expect(event.expiresAt).toBe(1_713_592_000_000); + expect(event.renewsAt).toBe(1_713_592_000_000); + expect(event.currency).toBe("KRW"); + expect(event.priceAmountMicros).toBe(12_900_000_000); + expect(event.occurredAt).toBe(1_711_000_000_000); + expect(event.sourceNotificationId).toBe("rtdn-msg-1"); + }); + + it("derives state from subscriptionsv2 when present, otherwise from event type", () => { + const grace = normalizeGoogleRtdn({ + payload: { + ...baseRtdnSubscription, + subscriptionNotification: { + ...baseRtdnSubscription.subscriptionNotification!, + notificationType: 6, + }, + }, + }); + expect(grace.type).toBe("SubscriptionInGracePeriod"); + expect(grace.subscriptionState).toBe("InGracePeriod"); + + const onHold = normalizeGoogleRtdn({ + payload: baseRtdnSubscription, + subscriptionInfo: { state: "SUBSCRIPTION_STATE_ON_HOLD" }, + }); + expect(onHold.subscriptionState).toBe("InBillingRetry"); + }); + + it("preserves Active state when SUBSCRIPTION_STATE_CANCELED reports auto-renew off", () => { + const event = normalizeGoogleRtdn({ + payload: { + ...baseRtdnSubscription, + subscriptionNotification: { + ...baseRtdnSubscription.subscriptionNotification!, + notificationType: 3, + }, + }, + subscriptionInfo: { state: "SUBSCRIPTION_STATE_CANCELED" }, + }); + expect(event.type).toBe("SubscriptionCanceled"); + expect(event.subscriptionState).toBe("Active"); + expect(event.cancellationReason).toBe("UserCanceled"); + }); + + it("translates Google cancelReason values to openiap reasons", () => { + const cases: Array<[string, string]> = [ + ["USER_CANCELED", "UserCanceled"], + ["BILLING_ERROR", "BillingError"], + ["SYSTEM_INITIATED_CANCELLATION", "BillingError"], + ["NEW_PRICE_REJECTED", "PriceIncreaseDeclined"], + ["DEVELOPER_CANCELED", "ProductUnavailable"], + ["UNKNOWN_NEW_REASON", "Other"], + ]; + for (const [cancelReason, expected] of cases) { + const event = normalizeGoogleRtdn({ + payload: { + ...baseRtdnSubscription, + subscriptionNotification: { + ...baseRtdnSubscription.subscriptionNotification!, + notificationType: 13, + }, + }, + subscriptionInfo: { cancelReason }, + }); + expect(event.type).toBe("SubscriptionExpired"); + expect(event.cancellationReason).toBe(expected); + } + }); + + it("normalizes a one-time refund into PurchaseRefunded", () => { + const event = normalizeGoogleRtdn({ + payload: { + messageId: "rtdn-otp-1", + packageName: "com.example.app", + eventTimeMillis: 1_711_111_111_000, + oneTimeProductNotification: { + notificationType: 2, + purchaseToken: "otp-token-xyz", + sku: "coin_pack_100", + }, + }, + }); + expect(event.type).toBe("PurchaseRefunded"); + expect(event.purchaseToken).toBe("otp-token-xyz"); + expect(event.productId).toBe("coin_pack_100"); + expect(event.subscriptionState).toBe("Refunded"); + expect(event.cancellationReason).toBe("Refunded"); + }); + + it("normalizes a voidedPurchase to PurchaseRefunded", () => { + const event = normalizeGoogleRtdn({ + payload: { + messageId: "rtdn-void-1", + packageName: "com.example.app", + eventTimeMillis: 1_711_222_222_000, + voidedPurchaseNotification: { + purchaseToken: "void-token-1", + orderId: "GPA.1234-5678-9012-34567", + productType: 1, + refundType: 1, + }, + }, + }); + expect(event.type).toBe("PurchaseRefunded"); + expect(event.purchaseToken).toBe("void-token-1"); + expect(event.cancellationReason).toBe("Refunded"); + }); + + it("normalizes a testNotification to Sandbox environment", () => { + const event = normalizeGoogleRtdn({ + payload: { + messageId: "rtdn-test-1", + eventTimeMillis: 1_711_000_000_000, + testNotification: { version: "1.0" }, + }, + }); + expect(event.type).toBe("TestNotification"); + expect(event.environment).toBe("Sandbox"); + // RTDN test payloads carry no transaction → no purchaseToken. + expect(event.purchaseToken).toBeUndefined(); + }); + + it("rejects RTDN payloads missing messageId", () => { + expect(() => + normalizeGoogleRtdn({ + payload: { + ...baseRtdnSubscription, + messageId: "", + }, + }), + ).toThrow(/messageId/); + }); + + it("rejects RTDN payloads with no notification variant", () => { + expect(() => + normalizeGoogleRtdn({ + payload: { + messageId: "x", + eventTimeMillis: 1, + }, + }), + ).toThrow(WebhookNormalizationError); + }); + + it("rejects unsupported RTDN subscription notification codes", () => { + expect(() => + normalizeGoogleRtdn({ + payload: { + ...baseRtdnSubscription, + subscriptionNotification: { + ...baseRtdnSubscription.subscriptionNotification!, + notificationType: 9999, + }, + }, + }), + ).toThrow(WebhookNormalizationError); + }); +}); diff --git a/packages/kit/convex/webhooks/shared.ts b/packages/kit/convex/webhooks/shared.ts new file mode 100644 index 00000000..85bbc1fe --- /dev/null +++ b/packages/kit/convex/webhooks/shared.ts @@ -0,0 +1,661 @@ +// Pure normalization helpers that map Apple ASN v2 and Google RTDN +// payloads to the unified `WebhookEvent` shape defined in +// `packages/gql/src/webhook.graphql`. +// +// This file is intentionally framework-free (no "use node", no Convex +// imports, no Apple/Google SDK imports) so it can run in the browser- +// safe Convex runtime, in vitest, and inside the Hono server bundle. +// The verifying receivers in `apple.ts` / `google.ts` decode the JWS +// (Apple) or Pub/Sub envelope (Google) and then hand the decoded +// payload here. +// +// SSOT for the mapping is `knowledge/external/webhook-mapping.md`. + +// --------------------------------------------------------------------------- +// Generated GraphQL spec mirrors. These literals MUST stay in sync with +// the enum values in `packages/gql/src/webhook.graphql`. +// --------------------------------------------------------------------------- + +export type WebhookEventType = + | "SubscriptionStarted" + | "SubscriptionRenewed" + | "SubscriptionExpired" + | "SubscriptionInGracePeriod" + | "SubscriptionInBillingRetry" + | "SubscriptionRecovered" + | "SubscriptionCanceled" + | "SubscriptionUncanceled" + | "SubscriptionRevoked" + | "SubscriptionPriceChange" + | "SubscriptionProductChanged" + | "SubscriptionPaused" + | "SubscriptionResumed" + | "PurchaseRefunded" + | "PurchaseConsumptionRequest" + | "TestNotification"; + +export type WebhookEventSource = + | "AppleAppStoreServerNotificationsV2" + | "GooglePlayRealTimeDeveloperNotifications"; + +export type WebhookEventEnvironment = "Production" | "Sandbox" | "Xcode"; + +export type SubscriptionState = + | "Active" + | "InGracePeriod" + | "InBillingRetry" + | "Expired" + | "Revoked" + | "Refunded" + | "Paused" + | "Unknown"; + +export type WebhookCancellationReason = + | "UserCanceled" + | "BillingError" + | "PriceIncreaseDeclined" + | "ProductUnavailable" + | "Refunded" + | "Other"; + +export type IapPlatform = "IOS" | "Android"; + +// Result returned by the pure normalizers. Mirrors the GraphQL +// `WebhookEvent` payload minus `id` / `projectId` / `receivedAt` / +// `rawSignedPayload`, which the action layer fills in. +export type NormalizedWebhookEvent = { + type: WebhookEventType; + source: WebhookEventSource; + platform: IapPlatform; + environment: WebhookEventEnvironment; + // TestNotification carries no transaction; all other event types + // populate this. The purchaseToken-or-throw guard lives in the + // platform-specific normalizers (normalizeAppleEvent / + // normalizeGoogleEvent). + purchaseToken: string | undefined; + productId?: string; + subscriptionState?: SubscriptionState; + expiresAt?: number; + renewsAt?: number; + cancellationReason?: WebhookCancellationReason; + currency?: string; + priceAmountMicros?: number; + occurredAt: number; + // Stable per-notification identifier used for idempotency. ASN v2 + // `notificationUUID` or RTDN `messageId`. + sourceNotificationId: string; +}; + +// --------------------------------------------------------------------------- +// Apple ASN v2 +// --------------------------------------------------------------------------- + +// Subset of the @apple/app-store-server-library types we care about, +// re-declared as plain shapes so this file can be imported from non- +// node Convex contexts (the SDK itself imports node:crypto). + +export type AppleAsnPayload = { + notificationType: string; + subtype?: string | null; + notificationUUID: string; + signedDate: number; + data?: AppleAsnData | null; +}; + +export type AppleAsnData = { + environment?: string | null; + bundleId?: string | null; + appAppleId?: number | null; + signedTransactionInfo?: string | null; + signedRenewalInfo?: string | null; +}; + +// Decoded JWS payloads — Apple's library exposes these via verifier +// methods. Only the fields the normalizer reads are listed. +export type AppleDecodedTransaction = { + originalTransactionId?: string | null; + transactionId?: string | null; + productId?: string | null; + expiresDate?: number | null; + revocationReason?: number | null; + currency?: string | null; + // ASN v2 reports `price` in **milliunits** — 1/1000 of a currency + // unit. Apple's docs use "milliunits" (NOT "millicents"). $9.99 is + // 9990 milliunits; convert to micros (1/1_000_000 of a unit) by + // multiplying by 1000. + // https://developer.apple.com/documentation/appstoreserverapi/jwstransactiondecodedpayload/price + price?: number | null; +}; + +export type AppleDecodedRenewalInfo = { + autoRenewStatus?: number | null; + autoRenewProductId?: string | null; + expirationIntent?: number | null; + gracePeriodExpiresDate?: number | null; + isInBillingRetryPeriod?: boolean | null; + renewalDate?: number | null; + recentSubscriptionStartDate?: number | null; +}; + +const APPLE_ENV_MAP: Record = { + Production: "Production", + Sandbox: "Sandbox", + Xcode: "Xcode", + // Apple's docs show capitalized values, but defensive lower-case + // handling avoids spurious "Unknown" mappings if Apple ever ships + // a sandbox quirk. + production: "Production", + sandbox: "Sandbox", + xcode: "Xcode", +}; + +function mapAppleEnvironment( + value: string | null | undefined, +): WebhookEventEnvironment { + if (!value) { + return "Production"; + } + return APPLE_ENV_MAP[value] ?? "Production"; +} + +// Maps (notificationType, subtype) -> WebhookEventType. Returns null +// when the combination is not observable in the openiap spec yet +// (e.g. OFFER_REDEEMED, RENEWAL_EXTENDED) — kit will record but not +// emit those. +export function mapAppleNotificationType( + notificationType: string, + subtype?: string | null, +): WebhookEventType | null { + switch (notificationType) { + case "SUBSCRIBED": + // INITIAL_BUY and RESUBSCRIBE both surface as SubscriptionStarted. + return "SubscriptionStarted"; + case "DID_RENEW": + // BILLING_RECOVERY subtype indicates the subscription is back from + // a failure state, not a routine renewal. + return subtype === "BILLING_RECOVERY" + ? "SubscriptionRecovered" + : "SubscriptionRenewed"; + case "EXPIRED": + return "SubscriptionExpired"; + case "DID_FAIL_TO_RENEW": + return subtype === "GRACE_PERIOD" + ? "SubscriptionInGracePeriod" + : "SubscriptionInBillingRetry"; + case "GRACE_PERIOD_EXPIRED": + // Grace period ended with no successful renewal — the subscription + // is now in billing retry / on hold. + return "SubscriptionInBillingRetry"; + case "DID_CHANGE_RENEWAL_STATUS": + if (subtype === "AUTO_RENEW_DISABLED") { + return "SubscriptionCanceled"; + } + if (subtype === "AUTO_RENEW_ENABLED") { + return "SubscriptionUncanceled"; + } + return null; + case "DID_CHANGE_RENEWAL_PREF": + return "SubscriptionProductChanged"; + case "PRICE_INCREASE": + return "SubscriptionPriceChange"; + case "REVOKE": + return "SubscriptionRevoked"; + case "REFUND": + return "PurchaseRefunded"; + case "REFUND_REVERSED": + // Refund reversed means the user retains their purchase — surface + // as a fresh "Started" so consumers re-grant entitlement. The raw + // payload still describes the reversal for consumers that need it. + return "SubscriptionStarted"; + case "CONSUMPTION_REQUEST": + return "PurchaseConsumptionRequest"; + case "TEST": + return "TestNotification"; + default: + return null; + } +} + +function mapAppleCancellationReason( + notificationType: string, + subtype: string | null | undefined, + renewalInfo: AppleDecodedRenewalInfo | null | undefined, +): WebhookCancellationReason | undefined { + if (notificationType === "REFUND" || notificationType === "REVOKE") { + return "Refunded"; + } + if ( + notificationType === "DID_CHANGE_RENEWAL_STATUS" && + subtype === "AUTO_RENEW_DISABLED" + ) { + return "UserCanceled"; + } + // Apple `expirationIntent`: + // 1: customer canceled, 2: billing error, 3: price increase + // declined, 4: product unavailable at renewal time, 5: unknown + if (renewalInfo?.expirationIntent != null) { + switch (renewalInfo.expirationIntent) { + case 1: + return "UserCanceled"; + case 2: + return "BillingError"; + case 3: + return "PriceIncreaseDeclined"; + case 4: + return "ProductUnavailable"; + case 5: + return "Other"; + } + } + return undefined; +} + +function deriveAppleSubscriptionState( + type: WebhookEventType, +): SubscriptionState | undefined { + switch (type) { + case "SubscriptionStarted": + case "SubscriptionRenewed": + case "SubscriptionRecovered": + case "SubscriptionUncanceled": + case "SubscriptionResumed": + case "SubscriptionPriceChange": + case "SubscriptionProductChanged": + return "Active"; + case "SubscriptionInGracePeriod": + return "InGracePeriod"; + case "SubscriptionInBillingRetry": + return "InBillingRetry"; + case "SubscriptionExpired": + return "Expired"; + case "SubscriptionRevoked": + return "Revoked"; + case "SubscriptionCanceled": + // User turned off auto-renew — access continues until expiry, so + // the subscription is still Active until the period ends. + return "Active"; + case "PurchaseRefunded": + return "Refunded"; + case "SubscriptionPaused": + return "Paused"; + default: + return undefined; + } +} + +export type NormalizeAppleInput = { + payload: AppleAsnPayload; + transaction?: AppleDecodedTransaction | null; + renewalInfo?: AppleDecodedRenewalInfo | null; +}; + +export class WebhookNormalizationError extends Error { + constructor( + message: string, + readonly code: + | "UnknownEventType" + | "MissingPurchaseToken" + | "MissingNotificationId", + ) { + super(message); + this.name = "WebhookNormalizationError"; + } +} + +export function normalizeAppleAsn( + input: NormalizeAppleInput, +): NormalizedWebhookEvent { + const { payload, transaction, renewalInfo } = input; + + if (!payload.notificationUUID) { + throw new WebhookNormalizationError( + "Apple ASN v2 payload is missing notificationUUID", + "MissingNotificationId", + ); + } + + const type = mapAppleNotificationType( + payload.notificationType, + payload.subtype ?? null, + ); + + if (type === null) { + throw new WebhookNormalizationError( + `Unsupported Apple notificationType: ${payload.notificationType} / subtype: ${payload.subtype ?? ""}`, + "UnknownEventType", + ); + } + + // TestNotification has no transaction/renewal data and therefore no + // purchaseToken — the schema's purchaseToken column is optional so + // we leave it undefined for those rows instead of synthesizing a + // placeholder (which would pollute by-token lookups). Dedup uses + // (projectId, source, sourceNotificationId) and doesn't depend on + // purchaseToken either, so the test event still flows end-to-end. + const purchaseToken = + transaction?.originalTransactionId ?? + transaction?.transactionId ?? + undefined; + + if (!purchaseToken && type !== "TestNotification") { + throw new WebhookNormalizationError( + "Apple ASN v2 payload missing originalTransactionId for non-test notification", + "MissingPurchaseToken", + ); + } + + // Apple reports `price` in milliunits (1/1000 of a currency unit — + // see the note on AppleDecodedTransaction.price above). openiap + // exposes micros (1/1_000_000) to match Google's + // `priceAmountMicros` convention, so milliunits → micros is a 1000× + // multiplier (e.g. $9.99 → 9990 milliunits → 9_990_000 micros). + const priceAmountMicros = + typeof transaction?.price === "number" + ? transaction.price * 1000 + : undefined; + + return { + type, + source: "AppleAppStoreServerNotificationsV2", + platform: "IOS", + environment: mapAppleEnvironment(payload.data?.environment ?? null), + purchaseToken, + productId: transaction?.productId ?? undefined, + subscriptionState: deriveAppleSubscriptionState(type), + expiresAt: transaction?.expiresDate ?? undefined, + renewsAt: renewalInfo?.renewalDate ?? undefined, + cancellationReason: mapAppleCancellationReason( + payload.notificationType, + payload.subtype ?? null, + renewalInfo ?? null, + ), + currency: transaction?.currency ?? undefined, + priceAmountMicros, + occurredAt: payload.signedDate, + sourceNotificationId: payload.notificationUUID, + }; +} + +// --------------------------------------------------------------------------- +// Google RTDN +// --------------------------------------------------------------------------- + +// RTDN payload shape — see +// https://developer.android.com/google/play/billing/rtdn-reference + +export type GoogleRtdnPayload = { + // Pub/Sub message id (used for idempotency). + messageId: string; + // ISO-8601 string from Pub/Sub publishTime; we accept either that or + // the RTDN body's `eventTimeMillis` and prefer the latter. + publishTimeMs?: number; + packageName?: string; + eventTimeMillis: number; + subscriptionNotification?: GoogleSubscriptionNotification | null; + oneTimeProductNotification?: GoogleOneTimeProductNotification | null; + voidedPurchaseNotification?: GoogleVoidedPurchaseNotification | null; + testNotification?: GoogleTestNotification | null; +}; + +export type GoogleSubscriptionNotification = { + // RTDN numeric type — see SUBSCRIPTION_NOTIFICATION_TYPE in the + // mapping doc. + notificationType: number; + purchaseToken: string; + subscriptionId: string; +}; + +export type GoogleOneTimeProductNotification = { + notificationType: number; + purchaseToken: string; + sku: string; +}; + +export type GoogleVoidedPurchaseNotification = { + purchaseToken: string; + orderId?: string; + productType?: number; + refundType?: number; +}; + +export type GoogleTestNotification = { + version: string; +}; + +// Optional supplemental data, fetched separately by the action via +// `androidpublisher.purchases.subscriptionsv2.get` because RTDN does +// not embed expiry / price information. +export type GoogleSubscriptionInfo = { + expiryTimeMillis?: number; + // Auto-renewing plans expose the next renewal time inline; one-off + // prepaid plans don't carry one. + autoRenewingPlanRenewsTimeMillis?: number; + state?: string; + cancelReason?: string; + currency?: string; + priceAmountMicros?: number; +}; + +// RTDN numeric codes per +// https://developer.android.com/google/play/billing/rtdn-reference#sub +// Codes 1 and 4 were swapped in an earlier draft (caught in PR #123 (https://github.com/hyodotdev/openiap/pull/123) +// review) — `1 = RECOVERED` and `4 = PURCHASED`. Code 7 = RESTARTED +// means the user re-enabled auto-renew while the subscription was +// still in its active period, which matches the +// `SubscriptionUncanceled` semantics, not `Started`. Code 11 = +// PAUSE_SCHEDULE_CHANGED fires when a pause is scheduled / changed, +// not on resume — collapsing it onto `Paused` keeps the event log +// honest (the actual end-of-pause appears as RENEWED/RECOVERED). +const GOOGLE_SUB_TYPE_MAP: Record = { + 1: "SubscriptionRecovered", // SUBSCRIPTION_RECOVERED + 2: "SubscriptionRenewed", // SUBSCRIPTION_RENEWED + 3: "SubscriptionCanceled", // SUBSCRIPTION_CANCELED + 4: "SubscriptionStarted", // SUBSCRIPTION_PURCHASED + 5: "SubscriptionInBillingRetry", // SUBSCRIPTION_ON_HOLD + 6: "SubscriptionInGracePeriod", // SUBSCRIPTION_IN_GRACE_PERIOD + 7: "SubscriptionUncanceled", // SUBSCRIPTION_RESTARTED — auto-renew re-enabled + 8: "SubscriptionPriceChange", // SUBSCRIPTION_PRICE_CHANGE_CONFIRMED + 9: "SubscriptionProductChanged", // SUBSCRIPTION_DEFERRED + 10: "SubscriptionPaused", // SUBSCRIPTION_PAUSED + 11: "SubscriptionPaused", // SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED — schedule change, not resume + 12: "SubscriptionRevoked", // SUBSCRIPTION_REVOKED + 13: "SubscriptionExpired", // SUBSCRIPTION_EXPIRED + // 19 = SUBSCRIPTION_PRICE_CHANGE_UPDATED — alias for code 8 + 19: "SubscriptionPriceChange", + // 20 = SUBSCRIPTION_PENDING_PURCHASE_CANCELED + 20: "SubscriptionCanceled", +}; + +const GOOGLE_ONE_TIME_TYPE_MAP: Record = { + // ONE_TIME_PRODUCT_PURCHASED — initial purchase. We re-use Started + // since openiap doesn't currently distinguish one-time activation. + 1: "SubscriptionStarted", + 2: "PurchaseRefunded", // ONE_TIME_PRODUCT_CANCELED +}; + +export function mapGoogleSubscriptionNotificationType( + notificationType: number, +): WebhookEventType | null { + return GOOGLE_SUB_TYPE_MAP[notificationType] ?? null; +} + +export function mapGoogleOneTimeNotificationType( + notificationType: number, +): WebhookEventType | null { + return GOOGLE_ONE_TIME_TYPE_MAP[notificationType] ?? null; +} + +function deriveGoogleSubscriptionState( + type: WebhookEventType, + info: GoogleSubscriptionInfo | null | undefined, +): SubscriptionState | undefined { + if (info?.state) { + // androidpublisher.subscriptionsv2 state values: + // SUBSCRIPTION_STATE_ACTIVE, SUBSCRIPTION_STATE_CANCELED, + // SUBSCRIPTION_STATE_IN_GRACE_PERIOD, SUBSCRIPTION_STATE_ON_HOLD, + // SUBSCRIPTION_STATE_PAUSED, SUBSCRIPTION_STATE_EXPIRED, + // SUBSCRIPTION_STATE_PENDING. + switch (info.state) { + case "SUBSCRIPTION_STATE_ACTIVE": + return "Active"; + case "SUBSCRIPTION_STATE_CANCELED": + // Auto-renew off but still has access until expiry — Active. + return "Active"; + case "SUBSCRIPTION_STATE_IN_GRACE_PERIOD": + return "InGracePeriod"; + case "SUBSCRIPTION_STATE_ON_HOLD": + return "InBillingRetry"; + case "SUBSCRIPTION_STATE_PAUSED": + return "Paused"; + case "SUBSCRIPTION_STATE_EXPIRED": + return "Expired"; + } + } + // Fallback when subscriptionsv2 was not fetched (e.g. action didn't + // have credentials yet, or this is a one-time / test notification). + switch (type) { + case "SubscriptionStarted": + case "SubscriptionRenewed": + case "SubscriptionRecovered": + case "SubscriptionResumed": + case "SubscriptionUncanceled": + case "SubscriptionPriceChange": + case "SubscriptionProductChanged": + return "Active"; + case "SubscriptionInGracePeriod": + return "InGracePeriod"; + case "SubscriptionInBillingRetry": + return "InBillingRetry"; + case "SubscriptionExpired": + return "Expired"; + case "SubscriptionRevoked": + return "Revoked"; + case "SubscriptionPaused": + return "Paused"; + case "SubscriptionCanceled": + return "Active"; + case "PurchaseRefunded": + return "Refunded"; + default: + return undefined; + } +} + +function mapGoogleCancellationReason( + type: WebhookEventType, + info: GoogleSubscriptionInfo | null | undefined, +): WebhookCancellationReason | undefined { + if (type === "PurchaseRefunded" || type === "SubscriptionRevoked") { + return "Refunded"; + } + if (info?.cancelReason) { + switch (info.cancelReason) { + case "USER_CANCELED": + return "UserCanceled"; + case "BILLING_ERROR": + case "SYSTEM_INITIATED_CANCELLATION": + return "BillingError"; + case "NEW_PRICE_REJECTED": + return "PriceIncreaseDeclined"; + case "DEVELOPER_CANCELED": + return "ProductUnavailable"; + default: + return "Other"; + } + } + if (type === "SubscriptionCanceled") { + return "UserCanceled"; + } + return undefined; +} + +export type NormalizeGoogleInput = { + payload: GoogleRtdnPayload; + subscriptionInfo?: GoogleSubscriptionInfo | null; +}; + +export function normalizeGoogleRtdn( + input: NormalizeGoogleInput, +): NormalizedWebhookEvent { + const { payload, subscriptionInfo } = input; + + if (!payload.messageId) { + throw new WebhookNormalizationError( + "Google RTDN payload missing messageId", + "MissingNotificationId", + ); + } + + // Determine the event flavor first — RTDN messages carry exactly one + // of (subscriptionNotification | oneTimeProductNotification | + // voidedPurchaseNotification | testNotification). + let type: WebhookEventType | null = null; + let purchaseToken: string | null = null; + let productId: string | undefined; + + if (payload.testNotification) { + type = "TestNotification"; + // RTDN test payloads carry no purchaseToken — leave it null so the + // optional schema column stays empty, matching the Apple side. + purchaseToken = null; + } else if (payload.subscriptionNotification) { + type = mapGoogleSubscriptionNotificationType( + payload.subscriptionNotification.notificationType, + ); + purchaseToken = payload.subscriptionNotification.purchaseToken; + productId = payload.subscriptionNotification.subscriptionId; + } else if (payload.oneTimeProductNotification) { + type = mapGoogleOneTimeNotificationType( + payload.oneTimeProductNotification.notificationType, + ); + purchaseToken = payload.oneTimeProductNotification.purchaseToken; + productId = payload.oneTimeProductNotification.sku; + } else if (payload.voidedPurchaseNotification) { + // VOIDED_PURCHASE always means the purchase was refunded / + // chargebacked — collapse to PurchaseRefunded regardless of code. + type = "PurchaseRefunded"; + purchaseToken = payload.voidedPurchaseNotification.purchaseToken; + } + + if (type === null) { + throw new WebhookNormalizationError( + "Unsupported Google RTDN payload variant", + "UnknownEventType", + ); + } + if (!purchaseToken && type !== "TestNotification") { + throw new WebhookNormalizationError( + "Google RTDN payload missing purchaseToken", + "MissingPurchaseToken", + ); + } + + // RTDN does not surface sandbox vs production — testNotification + // implies sandbox, otherwise prod. + const environment: WebhookEventEnvironment = payload.testNotification + ? "Sandbox" + : "Production"; + + return { + type, + source: "GooglePlayRealTimeDeveloperNotifications", + platform: "Android", + environment, + purchaseToken: purchaseToken ?? undefined, + productId, + subscriptionState: deriveGoogleSubscriptionState( + type, + subscriptionInfo ?? null, + ), + expiresAt: subscriptionInfo?.expiryTimeMillis, + renewsAt: subscriptionInfo?.autoRenewingPlanRenewsTimeMillis, + cancellationReason: mapGoogleCancellationReason( + type, + subscriptionInfo ?? null, + ), + currency: subscriptionInfo?.currency, + priceAmountMicros: subscriptionInfo?.priceAmountMicros, + occurredAt: payload.eventTimeMillis, + sourceNotificationId: payload.messageId, + }; +} diff --git a/packages/kit/convex/webhooks/validators.ts b/packages/kit/convex/webhooks/validators.ts new file mode 100644 index 00000000..30333475 --- /dev/null +++ b/packages/kit/convex/webhooks/validators.ts @@ -0,0 +1,68 @@ +import { v } from "convex/values"; + +// Convex validators for webhook enums. Re-used by both the internal +// mutation arguments and the public query return shape so the schema +// stays in sync without a hand-maintained second copy. +// +// Mirror of the GraphQL enums in `packages/gql/src/webhook.graphql`. + +export const webhookEventTypeValidator = v.union( + v.literal("SubscriptionStarted"), + v.literal("SubscriptionRenewed"), + v.literal("SubscriptionExpired"), + v.literal("SubscriptionInGracePeriod"), + v.literal("SubscriptionInBillingRetry"), + v.literal("SubscriptionRecovered"), + v.literal("SubscriptionCanceled"), + v.literal("SubscriptionUncanceled"), + v.literal("SubscriptionRevoked"), + v.literal("SubscriptionPriceChange"), + v.literal("SubscriptionProductChanged"), + v.literal("SubscriptionPaused"), + v.literal("SubscriptionResumed"), + v.literal("PurchaseRefunded"), + v.literal("PurchaseConsumptionRequest"), + v.literal("TestNotification"), +); + +export const webhookEventSourceValidator = v.union( + v.literal("AppleAppStoreServerNotificationsV2"), + v.literal("GooglePlayRealTimeDeveloperNotifications"), + // Synthetic source for Meta Horizon Store. Meta has no webhook / + // push notification system so kit polls `verify_entitlement` on a + // cron and emits these synthetic events when an entitlement + // transitions. SDK consumers see them on the SSE stream alongside + // real Apple / Google webhooks. + v.literal("MetaHorizonReconciler"), +); + +export const webhookEventEnvironmentValidator = v.union( + v.literal("Production"), + v.literal("Sandbox"), + v.literal("Xcode"), +); + +export const webhookEventPlatformValidator = v.union( + v.literal("IOS"), + v.literal("Android"), +); + +export const subscriptionStateValidator = v.union( + v.literal("Active"), + v.literal("InGracePeriod"), + v.literal("InBillingRetry"), + v.literal("Expired"), + v.literal("Revoked"), + v.literal("Refunded"), + v.literal("Paused"), + v.literal("Unknown"), +); + +export const webhookCancellationReasonValidator = v.union( + v.literal("UserCanceled"), + v.literal("BillingError"), + v.literal("PriceIncreaseDeclined"), + v.literal("ProductUnavailable"), + v.literal("Refunded"), + v.literal("Other"), +); diff --git a/packages/kit/package.json b/packages/kit/package.json index 69500787..125de194 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -41,6 +41,7 @@ "antd": "^6.1.0", "clsx": "^2.1.1", "convex": "^1.29.2", + "google-auth-library": "^10.6.2", "googleapis": "^157.0.0", "hono": "^4.9.9", "hono-openapi": "^1.1.0", diff --git a/packages/kit/public/llms.txt b/packages/kit/public/llms.txt index 4f6d8403..fa172f62 100644 --- a/packages/kit/public/llms.txt +++ b/packages/kit/public/llms.txt @@ -15,6 +15,8 @@ Base URL: https://kit.openiap.dev Auth: `Authorization: Bearer openiap-kit_` - [POST /v1/purchase/verify](https://kit.openiap.dev/docs/api) — verify an in-app purchase; body is a tagged union on `store` +- [POST /v1/webhooks/{apiKey}](https://www.openiap.dev/docs/webhooks#setup) — lifecycle webhook receiver. Paste this URL into App Store Connect (Production + Sandbox) AND Google Cloud Pub/Sub push subscription. Auto-detects ASN v2 vs Pub/Sub by payload shape. **POST-only**; opening in a browser returns 404 — that's expected. +- [GET /v1/webhooks/stream/{apiKey}](https://www.openiap.dev/docs/webhooks#consume-stream) — long-lived SSE stream of normalized `WebhookEvent`s. Connect with `EventSource` (or the per-SDK helper); reconnects honor `Last-Event-ID`. Opening in a browser shows a blank page (text/event-stream that never closes) — use the SDK helpers or `curl -N`. - [GET /v1/openapi](https://kit.openiap.dev/v1/openapi) — machine-readable OpenAPI spec - [GET /v1](https://kit.openiap.dev/v1) — Redoc UI for the OpenAPI spec - [GET /health](https://kit.openiap.dev/health) — liveness probe (no Convex round-trip) @@ -65,5 +67,6 @@ Harmonized `state` values (truthy `isValid`): `ENTITLED`, - [/docs/verification/horizon](https://kit.openiap.dev/docs/verification/horizon) — App ID + App Secret (write-only) - [/docs/api](https://kit.openiap.dev/docs/api) — request shapes, responses, errors, headers - [/docs/operations](https://kit.openiap.dev/docs/operations) — rate limits, logs, `/health`, graceful shutdown +- [openiap.dev/docs/webhooks](https://www.openiap.dev/docs/webhooks) — operator setup steps for the lifecycle webhook URL (Apple ASN v2 + Google RTDN) and SDK code for consuming the SSE stream - [/docs/ai-assistants](https://kit.openiap.dev/docs/ai-assistants) — how to point Claude / Cursor / etc. at this file - [/docs/release-notes](https://kit.openiap.dev/docs/release-notes) — changelog diff --git a/packages/kit/scripts/smoke-server.sh b/packages/kit/scripts/smoke-server.sh index 1842519a..c64e06bb 100755 --- a/packages/kit/scripts/smoke-server.sh +++ b/packages/kit/scripts/smoke-server.sh @@ -30,17 +30,23 @@ if [[ ! -f "$DIST/index.html" ]]; then exit 1 fi +# Per-process log file via mktemp so parallel CI matrix jobs don't +# interleave their writes into a shared `/tmp/openiap-kit-smoke.log` +# and cross-contaminate each other's failure output. +LOG_FILE="$(mktemp /tmp/openiap-kit-smoke.XXXXXX.log)" + # Run the binary in the background with placeholder env. CONVEX_URL="https://placeholder-build-1.convex.cloud" \ STATIC_ROOT="$DIST" \ PORT="$PORT" \ -"$BINARY" > /tmp/openiap-kit-smoke.log 2>&1 & +"$BINARY" > "$LOG_FILE" 2>&1 & PID=$! -# Always clean up the child, even on failure. +# Always clean up the child + temp log, even on failure. cleanup() { kill "$PID" 2>/dev/null || true wait "$PID" 2>/dev/null || true + rm -f "$LOG_FILE" } trap cleanup EXIT @@ -55,7 +61,7 @@ for _ in $(seq 1 20); do if ! kill -0 "$PID" 2>/dev/null; then echo "smoke: server process exited before /health responded" >&2 echo "---- server log ----" >&2 - cat /tmp/openiap-kit-smoke.log >&2 || true + cat "$LOG_FILE" >&2 || true exit 1 fi sleep 0.25 @@ -83,19 +89,19 @@ probe "/api/v1" "200" if [[ "$fail" -ne 0 ]]; then echo "---- server log ----" >&2 - cat /tmp/openiap-kit-smoke.log >&2 || true + cat "$LOG_FILE" >&2 || true exit 1 fi # HTTP probes pass even when the SPA bundle is broken at runtime -# (PR #120 shipped a chunk-cycle crash that left a 200 response and a +# (PR #120 (https://github.com/hyodotdev/openiap/pull/120) shipped a chunk-cycle crash that left a 200 response and a # blank page). Run a headless-browser smoke to catch that class of # failure. Skipped when SKIP_BROWSER_SMOKE=1 (e.g. dev iteration where # Playwright + chromium aren't installed yet). if [[ "${SKIP_BROWSER_SMOKE:-0}" != "1" ]]; then if ! SMOKE_URL="http://localhost:${PORT}/" bun run "$ROOT_DIR/scripts/smoke-browser.ts"; then echo "---- server log ----" >&2 - cat /tmp/openiap-kit-smoke.log >&2 || true + cat "$LOG_FILE" >&2 || true exit 1 fi fi diff --git a/packages/kit/server/api/v1/products.ts b/packages/kit/server/api/v1/products.ts new file mode 100644 index 00000000..717d264a --- /dev/null +++ b/packages/kit/server/api/v1/products.ts @@ -0,0 +1,218 @@ +import { Hono } from "hono"; + +import { api } from "@/convex"; +import { client } from "../../convex"; + +// Catalog read/write surface mirroring onesub's @onesub/providers +// admin path. The actual App Store Connect / Play Console push-sync +// is a Phase 3 follow-up; for now this manages the kit-side cache, +// which the dashboard / MCP server / SDKs all share. + +const products = new Hono(); + +products.get("/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + const platformParam = c.req.query("platform"); + const platform = + platformParam === "IOS" || platformParam === "Android" + ? platformParam + : undefined; + const list = await client.query(api.products.query.listProducts, { + apiKey, + platform, + }); + return c.json({ products: list }); +}); + +products.post("/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + let body: { + productId?: string; + platform?: "IOS" | "Android"; + type?: "Subscription" | "NonConsumable" | "Consumable"; + title?: string; + description?: string; + priceAmountMicros?: number; + currency?: string; + billingPeriod?: "P1W" | "P1M" | "P2M" | "P3M" | "P6M" | "P1Y"; + state?: "Draft" | "Ready" | "Active" | "Removed"; + storeRef?: string; + }; + try { + body = await c.req.json(); + } catch { + return c.json( + { errors: [{ code: "INVALID_INPUT", message: "Body is not JSON" }] }, + 400, + ); + } + if (!body.productId || !body.platform || !body.type || body.title == null) { + return c.json( + { + errors: [ + { + code: "INVALID_INPUT", + message: "productId, platform, type, title are required", + }, + ], + }, + 400, + ); + } + try { + const result = await client.mutation(api.products.mutation.upsertProduct, { + apiKey, + productId: body.productId, + platform: body.platform, + type: body.type, + title: body.title, + description: body.description, + priceAmountMicros: body.priceAmountMicros, + currency: body.currency, + billingPeriod: body.billingPeriod, + state: body.state, + storeRef: body.storeRef, + }); + return c.json(result); + } catch (error) { + return c.json( + { + errors: [ + { + code: "PRODUCT_UPSERT_FAILED", + message: error instanceof Error ? error.message : String(error), + }, + ], + }, + 400, + ); + } +}); + +// State-only update for the existing row. MCP `manage_product` uses +// this so it doesn't have to re-supply `type` / `title` (and thus +// can't accidentally clobber them, which the prior `upsertProduct` +// reuse pattern silently did). +products.post("/:apiKey/state", async (c) => { + const apiKey = c.req.param("apiKey"); + let body: { + productId?: string; + platform?: "IOS" | "Android"; + state?: "Draft" | "Ready" | "Active" | "Removed"; + }; + try { + body = await c.req.json(); + } catch { + return c.json( + { errors: [{ code: "INVALID_INPUT", message: "Body is not JSON" }] }, + 400, + ); + } + if (!body.productId || !body.platform || !body.state) { + return c.json( + { + errors: [ + { + code: "INVALID_INPUT", + message: "productId, platform, state are required", + }, + ], + }, + 400, + ); + } + try { + const result = await client.mutation( + api.products.mutation.setProductState, + { + apiKey, + productId: body.productId, + platform: body.platform, + state: body.state, + }, + ); + return c.json(result); + } catch (error) { + return c.json( + { + errors: [ + { + code: "PRODUCT_STATE_FAILED", + message: error instanceof Error ? error.message : String(error), + }, + ], + }, + 400, + ); + } +}); + +products.post("/:apiKey/sync/:platform", async (c) => { + const apiKey = c.req.param("apiKey"); + const platform = c.req.param("platform"); + const direction = + (c.req.query("direction") as "pull" | "push" | "both" | undefined) ?? + "both"; + if (platform !== "ios" && platform !== "android") { + return c.json( + { + errors: [ + { code: "INVALID_INPUT", message: "platform must be ios|android" }, + ], + }, + 400, + ); + } + try { + const result = + platform === "ios" + ? await client.action(api.products.asc.pushSyncProductsAppleIOS, { + apiKey, + direction, + }) + : await client.action(api.products.play.pushSyncProductsGoogle, { + apiKey, + direction, + }); + return c.json(result); + } catch (error) { + return c.json( + { + errors: [ + { + code: "PRODUCT_SYNC_FAILED", + message: error instanceof Error ? error.message : String(error), + }, + ], + }, + 400, + ); + } +}); + +products.delete("/:apiKey/:productId", async (c) => { + const apiKey = c.req.param("apiKey"); + const productId = c.req.param("productId"); + const platform = c.req.query("platform") as "IOS" | "Android" | undefined; + if (platform !== "IOS" && platform !== "Android") { + return c.json( + { + errors: [ + { + code: "INVALID_INPUT", + message: "platform query param required (IOS | Android)", + }, + ], + }, + 400, + ); + } + const result = await client.mutation(api.products.mutation.removeProduct, { + apiKey, + productId, + platform, + }); + return c.json(result); +}); + +export { products as productsRoutes }; diff --git a/packages/kit/server/api/v1/request-logger.test.ts b/packages/kit/server/api/v1/request-logger.test.ts index e14179ac..fcc6067e 100644 --- a/packages/kit/server/api/v1/request-logger.test.ts +++ b/packages/kit/server/api/v1/request-logger.test.ts @@ -50,9 +50,7 @@ function buildApp(params: { validator(verifyPurchaseInputSchema), (c) => { if (params.handler) { - return params.handler( - c as unknown as Parameters>[0], - ); + return params.handler(c); } c.set("verifyOutcome", { isValid: true, state: "ENTITLED" }); return c.json({ isValid: true, state: "ENTITLED" }); diff --git a/packages/kit/server/api/v1/routes.ts b/packages/kit/server/api/v1/routes.ts index 21307015..e8c4824f 100644 --- a/packages/kit/server/api/v1/routes.ts +++ b/packages/kit/server/api/v1/routes.ts @@ -19,6 +19,9 @@ import { rateLimitMiddleware } from "./rate-limit"; import { replayGuardMiddleware } from "./replay-guard"; import { requestLoggerMiddleware } from "./request-logger"; import { validator } from "./validator"; +import { webhooksRoutes } from "./webhooks"; +import { subscriptionsRoutes } from "./subscriptions"; +import { productsRoutes } from "./products"; // Variables that the request middleware chain attaches to the Hono // context. Declaring them here (and passing the generic to `new Hono()`) @@ -456,4 +459,27 @@ app.post( // same handler with the same middleware stack. app.post("/verify-purchase", ...verifyMiddleware); +// Lifecycle webhook receivers — Apple App Store Server Notifications v2 +// and Google Pub/Sub RTDN. These bypass the apiKeyMiddleware / +// rate-limit / replay-guard chain because Apple cannot send custom +// auth headers and Google's Pub/Sub push has its own delivery +// guarantees. Auth is enforced inside the receiver: +// - Apple: project apiKey is in the path; the action verifies the +// signedPayload against Apple's root certificates so a leaked URL +// can't be used to inject forged events. +// - Google: OIDC bearer JWT (when GOOGLE_PUBSUB_PUSH_AUDIENCE is +// configured) plus the path apiKey. +app.route("/webhooks", webhooksRoutes); + +// Subscription state, entitlements, metrics, and SDK user-binding. +// Provides the `/onesub/status` analog (`/v1/subscriptions/status/{apiKey}`) +// plus the multi-product entitlements view that onesub gates feature +// access on, and the metrics summary used by the kit dashboard. +app.route("/subscriptions", subscriptionsRoutes); + +// Product catalog (kit-side cache shared by the dashboard, MCP server, +// and SDK helpers). Phase 3 will extend this with App Store Connect / +// Play Developer push-sync; the surface stays the same. +app.route("/products", productsRoutes); + export { app as apiRoutes }; diff --git a/packages/kit/server/api/v1/subscriptions.ts b/packages/kit/server/api/v1/subscriptions.ts new file mode 100644 index 00000000..5872bfdd --- /dev/null +++ b/packages/kit/server/api/v1/subscriptions.ts @@ -0,0 +1,122 @@ +import { Hono } from "hono"; + +import { api } from "@/convex"; +import { client } from "../../convex"; + +// Subscription state, entitlements, metrics, and user-binding routes. +// Mirrors the role of onesub's `/onesub/status`, `/onesub/admin/...` +// and `/onesub/metrics/*` endpoints, but with kit-style apiKey-in-path +// auth so the routes work without sticky bearer headers from RN-side +// fetch implementations that strip them. + +const subscriptions = new Hono(); + +subscriptions.get("/status/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + const userId = c.req.query("userId"); + if (!userId) { + return c.json( + { errors: [{ code: "INVALID_INPUT", message: "userId is required" }] }, + 400, + ); + } + if (userId.length > 256) { + return c.json( + { + errors: [ + { code: "INVALID_INPUT", message: "userId must be ≤ 256 chars" }, + ], + }, + 400, + ); + } + const result = await client.query( + api.subscriptions.query.subscriptionStatus, + { + apiKey, + userId, + }, + ); + return c.json(result); +}); + +subscriptions.get("/entitlements/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + const userId = c.req.query("userId"); + if (!userId) { + return c.json( + { errors: [{ code: "INVALID_INPUT", message: "userId is required" }] }, + 400, + ); + } + const result = await client.query(api.subscriptions.query.entitlements, { + apiKey, + userId, + }); + return c.json(result); +}); + +subscriptions.get("/list/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + const state = c.req.query("state"); + const productId = c.req.query("productId"); + const userId = c.req.query("userId"); + const limit = parseLimit(c.req.query("limit")); + const result = await client.query(api.subscriptions.query.listSubscriptions, { + apiKey, + state: state as never, + productId: productId ?? undefined, + userId: userId ?? undefined, + limit, + }); + return c.json(result); +}); + +subscriptions.get("/metrics/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + const result = await client.query(api.subscriptions.query.metricsSummary, { + apiKey, + }); + return c.json(result); +}); + +subscriptions.post("/bind-user/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + let body: { purchaseToken?: string; userId?: string }; + try { + body = await c.req.json<{ purchaseToken?: string; userId?: string }>(); + } catch { + return c.json( + { errors: [{ code: "INVALID_INPUT", message: "Body is not JSON" }] }, + 400, + ); + } + if (!body.purchaseToken || !body.userId) { + return c.json( + { + errors: [ + { + code: "INVALID_INPUT", + message: "purchaseToken and userId are required", + }, + ], + }, + 400, + ); + } + const result = await client.mutation(api.subscriptions.mutation.bindUser, { + apiKey, + purchaseToken: body.purchaseToken, + userId: body.userId, + }); + return c.json(result); +}); + +function parseLimit(raw: string | undefined): number | undefined { + if (!raw) return undefined; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return undefined; + return Math.min(Math.max(Math.trunc(n), 1), 200); +} + +export { subscriptions as subscriptionsRoutes }; diff --git a/packages/kit/server/api/v1/validator.ts b/packages/kit/server/api/v1/validator.ts index cfd9f070..30ca97b5 100644 --- a/packages/kit/server/api/v1/validator.ts +++ b/packages/kit/server/api/v1/validator.ts @@ -14,28 +14,32 @@ type ValidatorResult = export function validator[1]>( schema: Schema, ) { - return honoValidator("json", schema, (( - result: ValidatorResult, - // Hono context is typed as `any`-generic here intentionally — see - // comment above. We use only `c.json(...)`, which is stable. - c: { json: (body: unknown, status: number) => Response }, - ) => { - if (result.success) { - return; - } + return honoValidator( + "json", + schema, + ( + result: ValidatorResult, + // Hono context is typed as `any`-generic here intentionally — see + // comment above. We use only `c.json(...)`, which is stable. + c: { json: (body: unknown, status: number) => Response }, + ) => { + if (result.success) { + return; + } - const errors = []; + const errors = []; - for (const issue of result.error) { - errors.push({ - code: "INVALID_INPUT", - message: issue.message, - path: issuePathToString(issue.path), - }); - } + for (const issue of result.error) { + errors.push({ + code: "INVALID_INPUT", + message: issue.message, + path: issuePathToString(issue.path), + }); + } - return c.json({ errors }, 400); - }) as Parameters[2]); + return c.json({ errors }, 400); + }, + ); } function issuePathToString( @@ -63,7 +67,7 @@ function issuePathToString( // path like `["a", unknown, "b"]` would serialize to `"a..b"` // and break client-side error mapping. if (segment !== null && typeof segment === "object" && "key" in segment) { - const key = (segment as { key: unknown }).key; + const key = segment.key; if (typeof key === "string") { segments.push(key); } else if (typeof key === "number") { diff --git a/packages/kit/server/api/v1/webhooks.ts b/packages/kit/server/api/v1/webhooks.ts new file mode 100644 index 00000000..ae39243b --- /dev/null +++ b/packages/kit/server/api/v1/webhooks.ts @@ -0,0 +1,921 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; +import { streamSSE } from "hono/streaming"; +import { OAuth2Client } from "google-auth-library"; +import { ConvexClient } from "convex/browser"; + +import { api } from "@/convex"; +import { client, convexUrlForRealtime, handleConvexError } from "../../convex"; + +// Shared reactive client for the SSE webhook stream. We keep a +// SINGLE WebSocket open to Convex regardless of how many SDK clients +// are subscribed — the previous per-connection `new ConvexClient(...)` +// inside streamSSE fanned out to one WebSocket per subscriber, which +// scaled poorly under typical traffic where every dashboard tab + every +// mobile SDK opens its own SSE connection. Each per-connection +// `onUpdate(...)` returns its own unsubscribe handle so isolating +// the *subscription lifecycle* per request still works correctly. +// +// Initialized lazily on first SSE connection — module-level +// instantiation would open the WebSocket at boot time, which (a) +// breaks the smoke build (placeholder CONVEX_URL → infinite reconnect +// loop blocks `server.stop()` shutdown) and (b) wastes a connection +// on processes that never serve a stream request. +let sharedReactiveClient: ConvexClient | null = null; +function getSharedReactiveClient(): ConvexClient { + sharedReactiveClient ??= new ConvexClient(convexUrlForRealtime); + return sharedReactiveClient; +} + +// Inbound webhook receivers for Apple ASN v2 and Google Pub/Sub RTDN. +// +// Auth model: +// - Apple ASN does not support custom Authorization headers, so the +// project's API key is encoded in the path: kit gives each project a +// webhook URL of the form +// https://kit.openiap.dev/v1/webhooks/apple/{apiKey} +// to register in App Store Connect. The path segment behaves like a +// capability token; rotating the project's API key invalidates the +// URL just like it invalidates verifyReceipt callers. The Convex +// action verifies the signedPayload signature against Apple's roots, +// so even if the URL leaks, only Apple-signed payloads are accepted. +// +// - Google Pub/Sub push delivers a Bearer JWT from Google in the +// Authorization header that we verify against +// https://www.googleapis.com/oauth2/v1/certs (via OAuth2Client). The +// project's API key is also in the path so kit can resolve which +// project a notification belongs to. Both checks must pass. + +const webhooks = new Hono(); + +// Unified lifecycle endpoint. The exact same URL works for both Apple +// App Store Connect and Google Pub/Sub push subscriptions: kit +// inspects the body shape to detect which store sent the +// notification, then dispatches to the same Convex action that the +// platform-specific paths use. +// +// Detection rules: +// - Apple ASN v2 payload: `{ "signedPayload": "" }` +// - Google Pub/Sub push: `{ "message": { "data": "", +// "messageId": "..." }, "subscription": "..." }` +// Anything else returns 400 INVALID_INPUT so misconfigured upstream +// senders fail loudly rather than silently being dropped. +// +// Why one URL: the comparison-table feedback was that exposing two +// "Apple URL" / "Google URL" copy boxes in the dashboard makes the +// hosted-backend pitch leakier than it needs to be. With one URL, +// the operator pastes the same string into App Store Connect AND +// Google Pub/Sub; whichever platform they haven't configured simply +// never sends traffic, and kit's per-platform receiver code only +// runs when its expected payload shape arrives. +const unifiedHandler = async (c: Context) => { + const apiKey = c.req.param("apiKey"); + if (!apiKey) { + return c.json( + { + errors: [ + { code: "INVALID_INPUT", message: "Missing apiKey path segment" }, + ], + }, + 400, + ); + } + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json( + { errors: [{ code: "INVALID_INPUT", message: "Body is not JSON" }] }, + 400, + ); + } + + // Setup-status gating now lives INSIDE the ingest actions + // (`ingestAppleAsnIOS` / `ingestGoogleRtdn`) — they already query + // the project once and throw structured ConvexError codes + // (`INVALID_API_KEY` / `IOS_NOT_CONFIGURED` / `ANDROID_NOT_CONFIGURED`) + // that `mapWebhookError` translates to the right HTTP status. + // Doing the check here as a separate Convex query was adding an + // extra round-trip per webhook. + if (looksLikeApple(body)) { + return handleAppleNotification( + c, + apiKey, + body as { signedPayload: string }, + ); + } + if (looksLikeGoogle(body)) { + return handleGoogleNotification(c, apiKey, body as PubSubPushBody); + } + + return c.json( + { + errors: [ + { + code: "INVALID_INPUT", + message: + "Unrecognized payload. Expected Apple ASN v2 ({signedPayload}) or Google Pub/Sub ({message:{data,messageId}}).", + }, + ], + }, + 400, + ); +}; + +// Public — paste this URL into both App Store Connect and Google +// Pub/Sub push subscription configuration. +webhooks.post("/:apiKey", unifiedHandler); + +// Backwards-compatible aliases for operators who already configured a +// platform-specific URL. Both dispatch through the same handlers as +// the unified endpoint, so the dashboard / docs nudge users toward +// the one-URL pattern without breaking existing wiring. +webhooks.post("/apple/:apiKey", unifiedHandler); +webhooks.post("/google/:apiKey", unifiedHandler); + +type PubSubPushBody = { + message: { + data: string; + messageId: string; + publishTime?: string; + attributes?: Record; + }; + subscription?: string; +}; + +function looksLikeApple(body: unknown): boolean { + return ( + !!body && + typeof body === "object" && + "signedPayload" in body && + typeof (body as Record).signedPayload === "string" + ); +} + +function looksLikeGoogle(body: unknown): boolean { + if (!body || typeof body !== "object") return false; + const message = (body as { message?: unknown }).message; + if (!message || typeof message !== "object") return false; + const m = message as Record; + return typeof m.data === "string" && typeof m.messageId === "string"; +} + +async function handleAppleNotification( + c: Context, + apiKey: string, + body: { signedPayload: string }, +) { + if (typeof body.signedPayload !== "string" || body.signedPayload.length < 1) { + return c.json( + { + errors: [ + { + code: "INVALID_INPUT", + message: "Missing or invalid signedPayload", + }, + ], + }, + 400, + ); + } + try { + const result = await client.action(api.webhooks.apple.ingestAppleAsnIOS, { + apiKey, + signedPayload: body.signedPayload, + }); + return c.json({ + ok: true, + eventType: result.type, + deduped: result.deduped, + }); + } catch (error) { + return mapWebhookError(c, error, "apple"); + } +} + +async function handleGoogleNotification( + c: Context, + apiKey: string, + body: PubSubPushBody, +) { + // Pub/Sub push always sends `Authorization: Bearer ` when OIDC + // is configured on the subscription. We require the operator to have + // set GOOGLE_PUBSUB_PUSH_AUDIENCE in production so kit fails closed + // — a missing env var must not silently let anonymous bodies through + // a Google-shaped path. In development / sandbox, the operator can + // opt out by setting `KIT_ALLOW_UNAUTHENTICATED_PUBSUB=1`. + const authHeader = c.req.header("authorization"); + const audience = process.env.GOOGLE_PUBSUB_PUSH_AUDIENCE; + const allowUnauth = process.env.KIT_ALLOW_UNAUTHENTICATED_PUBSUB === "1"; + if (!audience) { + if (!allowUnauth) { + console.error( + "[webhooks/google] GOOGLE_PUBSUB_PUSH_AUDIENCE is unset; rejecting request. Set KIT_ALLOW_UNAUTHENTICATED_PUBSUB=1 only for local dev.", + ); + return c.json( + { + errors: [ + { + code: "MISCONFIGURED", + message: + "Pub/Sub OIDC audience is not configured on this kit instance", + }, + ], + }, + 503, + ); + } + console.warn( + "[webhooks/google] GOOGLE_PUBSUB_PUSH_AUDIENCE unset and KIT_ALLOW_UNAUTHENTICATED_PUBSUB=1 — accepting unauthenticated Pub/Sub body (dev mode only).", + ); + } else { + const ok = await verifyPubSubOidcToken(authHeader, audience); + if (!ok) { + return c.json( + { + errors: [ + { + code: "UNAUTHORIZED", + message: "Pub/Sub OIDC verification failed", + }, + ], + }, + 401, + ); + } + } + + // Decode the Pub/Sub `message.data` once and keep both the parsed + // form (used to build the typed payload) and the original UTF-8 text + // (passed through as `rawMessage` so consumers / auditors / future + // signature verifiers see exactly what Google sent — JSON.stringify + // would normalize spacing + key order and break any byte-level + // verification). + let decodedRaw: string; + let decoded: Record; + try { + decodedRaw = Buffer.from(body.message.data, "base64").toString("utf-8"); + decoded = JSON.parse(decodedRaw); + } catch { + return c.json( + { + errors: [ + { + code: "INVALID_INPUT", + message: "Pub/Sub message.data is not base64-encoded JSON", + }, + ], + }, + 400, + ); + } + + const payload = { + messageId: body.message.messageId, + packageName: + typeof decoded.packageName === "string" ? decoded.packageName : undefined, + eventTimeMillis: + typeof decoded.eventTimeMillis === "string" + ? Number(decoded.eventTimeMillis) + : typeof decoded.eventTimeMillis === "number" + ? decoded.eventTimeMillis + : Date.parse(body.message.publishTime ?? "") || Date.now(), + subscriptionNotification: decoded.subscriptionNotification as + | undefined + | { + notificationType: number; + purchaseToken: string; + subscriptionId: string; + }, + oneTimeProductNotification: decoded.oneTimeProductNotification as + | undefined + | { notificationType: number; purchaseToken: string; sku: string }, + voidedPurchaseNotification: decoded.voidedPurchaseNotification as + | undefined + | { + purchaseToken: string; + orderId?: string; + productType?: number; + refundType?: number; + }, + testNotification: decoded.testNotification as + | undefined + | { version: string }, + }; + + try { + const result = await client.action(api.webhooks.google.ingestGoogleRtdn, { + apiKey, + rawMessage: decodedRaw, + payload, + }); + return c.json({ + ok: true, + eventType: result.type, + deduped: result.deduped, + }); + } catch (error) { + return mapWebhookError(c, error, "google"); + } +} + +// Server-Sent Events stream of normalized webhook events tied to the +// caller's API key. Per-connection, we open a Convex `onUpdate` +// subscription against `webhookEventsSince(apiKey, sinceMs)` so kit +// pushes new events to the SSE client the moment Convex commits them. +// No polling — Convex's reactive query is the source of liveness. +// +// Protocol: +// GET /v1/webhooks/stream/:apiKey +// +// Response: text/event-stream with one event per webhook, +// id: +// event: +// data: +// +// Reconnection: the standard `Last-Event-ID` header is honored on +// reconnect — kit looks up that event's `receivedAt` and resumes +// from there, so events that fired while the connection was closed +// are delivered in order on the next connect. +// +// Heartbeat: an SSE `event: heartbeat` is emitted every 25s so +// intermediate proxies (Fly edge, Cloudflare, browser fetch) don't +// close the idle connection. +const HEARTBEAT_MS = 25_000; + +// Drop fields the client doesn't need over the wire. `rawSignedPayload` +// holds the original JWS / Pub/Sub envelope including the upstream +// signature. Until kit grows per-purchaser SSE auth (tracked as +// follow-up — see PR #124 (https://github.com/hyodotdev/openiap/pull/124) review), the SSE feed is gated only by the +// project API key, so any holder of that key would otherwise see +// every other customer's signed payload. The client doesn't need it +// for normal reconciliation flows: `purchaseToken` + `productId` are +// enough to match against local state. Operators that DO need the +// raw payload can fetch it through an authenticated server-to-server +// query rather than a long-lived browser-readable stream. +function redactWebhookEventForStream( + event: Record, +): Record { + const { rawSignedPayload: _omit, ...rest } = event; + void _omit; + return rest; +} + +webhooks.get("/stream/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + const lastEventId = c.req.header("last-event-id") ?? undefined; + + // Validate the API key BEFORE entering streamSSE. If the key is + // wrong / rotated, every downstream `webhookEventsSince(apiKey, …)` + // call returns `[]`, which in the absence of this guard makes the + // SSE handler emit `ready` plus heartbeats forever — clients silently + // never receive lifecycle updates after a key rotation. Returning a + // 401 surfaces the misconfiguration immediately instead of looking + // like a healthy idle stream. + const project = await client.query(api.projects.query.getProjectByApiKey, { + apiKey, + }); + if (!project) { + return c.json( + { + errors: [ + { + code: "INVALID_API_KEY", + message: + "Project API key not recognized. Generate a fresh key in the kit dashboard or check that the key was not rotated.", + }, + ], + }, + 401, + ); + } + + const startCursor = await resolveStreamStartCursor(apiKey, lastEventId); + + return streamSSE(c, async (stream) => { + let aborted = false; + stream.onAbort(() => { + aborted = true; + }); + + // Use the lazy module-level shared client so we don't open a new + // WebSocket per SSE subscriber. Each subscription's lifecycle is + // bound to its own `unsubscribe` handle returned by + // `reactive.onUpdate(...)`, so isolation between connections is + // preserved without paying the per-connection WebSocket cost. + const reactive = getSharedReactiveClient(); + // Bounded dedup tracker: caps at SEEN_MAX entries with FIFO + // eviction so a long-lived SSE connection (days/weeks) can't + // grow the Set unbounded under high event volume. The window + // The cap kicks in only after the backlog drain (Phase 1) + // completes and we've armed the live tail (Phase 2). During + // Phase 1 we hold every drained id so the Phase 2 onUpdate + // can't double-deliver an event Phase 1 already emitted — + // even if the backlog runs to tens of thousands of rows. + // After Phase 2 is armed we only need a window long enough to + // cover the ~5s overlap between phases plus normal-traffic + // jitter; 5000 entries (~5 min of high-volume webhook traffic) + // comfortably bounds that. + const SEEN_MAX_AFTER_DRAIN = 5000; + let drainComplete = false; + const seenOrder: string[] = []; + const seenSet = new Set(); + const seen = { + has(id: string): boolean { + return seenSet.has(id); + }, + add(id: string): void { + if (seenSet.has(id)) return; + seenSet.add(id); + seenOrder.push(id); + if (drainComplete && seenOrder.length > SEEN_MAX_AFTER_DRAIN) { + const evicted = seenOrder.shift(); + if (evicted !== undefined) seenSet.delete(evicted); + } + }, + // Called by the SSE handler when Phase 1 finishes draining + // and Phase 2 has been registered. The pre-drain ids are now + // safe to age out under the SEEN_MAX_AFTER_DRAIN bound — the + // overlap-window guarantee only needs the tail. + markDrainComplete(): void { + drainComplete = true; + // Trim immediately so we don't carry a multi-minute backlog + // forever just because the drain ran long. + while (seenOrder.length > SEEN_MAX_AFTER_DRAIN) { + const evicted = seenOrder.shift(); + if (evicted !== undefined) seenSet.delete(evicted); + } + }, + }; + // `liveStart` is the boundary between the backlog drain (paginated + // HTTP queries) and the live tail (Convex `onUpdate` subscription + // pinned to `sinceMs = liveStart`). Convex pins query args at + // subscription time and won't refresh them as cursors advance — + // attaching `onUpdate` with the original cursor would create a + // 500-row result window that never moves forward, so new events + // beyond the initial batch would never reach the consumer (PR #124 (https://github.com/hyodotdev/openiap/pull/124) + // review fix). Draining first, then pinning the live tail at + // "now", sidesteps that limitation. + const liveStart = Date.now(); + + await stream.writeSSE({ + event: "ready", + data: JSON.stringify({ cursor: startCursor.sinceMs }), + }); + + // ── Phase 1: drain backlog ─────────────────────────────────── + // Pull every event between the reconnect cursor and `liveStart` + // through paginated `webhookEventsSince` calls. The cursor pair + // (`sinceMs`, `afterCreationTime`) is honored by the query so + // same-`receivedAt` cohorts larger than `limit` still advance. + let drainCursor = startCursor.sinceMs; + let drainCreationCursor = startCursor.afterCreationTime; + // Tracks the receivedAt of the last event we *delivered* (not the + // last cursor position) so we can detect a saturated-cohort + // stall: if a follow-up query returns empty while we just + // delivered events at drainCursor's millisecond, the + // receivedAt-keyed query bound (limit=5000) fell short of the + // full cohort. Bumping drainCursor by 1ms in that case sacrifices + // any remaining events past the 5000-event-per-ms threshold but + // guarantees forward progress — and the threshold is a hard upper + // bound that no real-world store webhook ever approaches. + let lastDeliveredReceivedAt: number | null = null; + // Hard safety bounds on the drain loop. Without these, a project + // with a very large 30-day backlog could keep one connection + // running for an unbounded amount of time, holding the kit pod's + // SSE budget. Hitting either bound stops drain phase and lets + // Phase 2 take over from the live cursor — clients can reconnect + // with their last received id to continue. + const DRAIN_MAX_ITERATIONS = 200; // 200 * 500-row pages = 100k events + const DRAIN_MAX_MS = 60_000; // wall-clock cap (1 min) + const drainStartedAt = Date.now(); + let drainIterations = 0; + try { + while (!aborted) { + if (drainIterations >= DRAIN_MAX_ITERATIONS) { + console.warn( + "[webhooks/stream] drain hit DRAIN_MAX_ITERATIONS — handing off to live tail", + { drainIterations, drainCursor }, + ); + break; + } + if (Date.now() - drainStartedAt > DRAIN_MAX_MS) { + console.warn( + "[webhooks/stream] drain hit DRAIN_MAX_MS — handing off to live tail", + { elapsedMs: Date.now() - drainStartedAt, drainCursor }, + ); + break; + } + drainIterations += 1; + const batch = (await client.query( + api.webhooks.query.webhookEventsSince, + { + apiKey, + sinceMs: drainCursor, + afterCreationTime: drainCreationCursor, + limit: 500, + }, + )) as Array>; + if (!batch.length) { + // Saturated-cohort fallback: if the previous iteration + // delivered events stuck at drainCursor's millisecond and + // this query came back empty, the query's fetchLimit cap + // hid the rest of that cohort. Advance past the millisecond + // and try once more before declaring drain complete. + if ( + lastDeliveredReceivedAt !== null && + lastDeliveredReceivedAt === drainCursor + ) { + drainCursor += 1; + drainCreationCursor = undefined; + lastDeliveredReceivedAt = null; + continue; + } + break; + } + + let advanced = false; + for (const event of batch) { + if (aborted) break; + const id = typeof event.id === "string" ? event.id : null; + if (!id || seen.has(id)) continue; + // Stop the drain once we've crossed into "live" territory — + // events at or past `liveStart` are owned by the live tail. + if ( + typeof event.receivedAt === "number" && + event.receivedAt >= liveStart + ) { + break; + } + seen.add(id); + if ( + typeof event.receivedAt === "number" && + event.receivedAt > drainCursor + ) { + drainCursor = event.receivedAt; + advanced = true; + } + if ( + typeof event._creationTime === "number" && + (drainCreationCursor === undefined || + event._creationTime > drainCreationCursor) + ) { + drainCreationCursor = event._creationTime; + advanced = true; + } + await stream + .writeSSE({ + id, + event: + typeof event.type === "string" ? event.type : "WebhookEvent", + data: JSON.stringify(redactWebhookEventForStream(event)), + }) + .catch((err) => { + console.error("[webhooks/stream] drain write failed", err); + }); + if (typeof event.receivedAt === "number") { + lastDeliveredReceivedAt = event.receivedAt; + } + } + if (!advanced) break; + if (batch.length < 500) break; + } + } catch (error) { + console.error("[webhooks/stream] drain failed", error); + await stream.writeSSE({ + event: "stream-error", + data: JSON.stringify({ + message: error instanceof Error ? error.message : "Drain failed", + }), + }); + // No reactive.close() — the client is shared across SSE + // subscribers. We never registered an `onUpdate` here (still + // in the drain phase), so there's nothing per-connection to + // tear down. + return; + } + if (aborted) { + return; + } + + // ── Phase 2: attach live tail ──────────────────────────────── + // Convex reactive query args are immutable for the life of an + // `onUpdate` subscription, so the subscription itself cannot be + // the delivery cursor. Use it only as a wake-up signal, then drain + // through `webhookEventsSince` with a per-connection moving cursor. + // That keeps long-lived streams moving past every 500-row page. + // + // The overlap closes a small race window: an event committed with + // `receivedAt` marginally before `liveStart` would otherwise be + // missed by both phases. `seen` dedupes the overlap. + const PHASE_OVERLAP_MS = 5_000; + let liveCursor = liveStart - PHASE_OVERLAP_MS; + let liveCreationCursor: number | undefined; + let liveDraining = false; + let liveDrainRequested = false; + const drainLiveTail = async (): Promise => { + if (liveDraining) { + liveDrainRequested = true; + return; + } + liveDraining = true; + try { + do { + liveDrainRequested = false; + let iterations = 0; + while (!aborted) { + if (iterations >= DRAIN_MAX_ITERATIONS) { + console.warn( + "[webhooks/stream] live drain hit DRAIN_MAX_ITERATIONS", + { iterations, liveCursor }, + ); + break; + } + iterations += 1; + const batch = (await client.query( + api.webhooks.query.webhookEventsSince, + { + apiKey, + sinceMs: liveCursor, + afterCreationTime: liveCreationCursor, + limit: 500, + }, + )) as Array>; + if (!batch.length) { + break; + } + + let advanced = false; + for (const event of batch) { + if (aborted) break; + const receivedAt = + typeof event.receivedAt === "number" ? event.receivedAt : null; + const creationTime = + typeof event._creationTime === "number" + ? event._creationTime + : undefined; + if ( + receivedAt !== null && + (receivedAt > liveCursor || + (receivedAt === liveCursor && + creationTime !== undefined && + (liveCreationCursor === undefined || + creationTime > liveCreationCursor))) + ) { + liveCursor = receivedAt; + liveCreationCursor = creationTime; + advanced = true; + } + + const id = typeof event.id === "string" ? event.id : null; + if (!id || seen.has(id)) continue; + seen.add(id); + await stream + .writeSSE({ + id, + event: + typeof event.type === "string" + ? event.type + : "WebhookEvent", + data: JSON.stringify(redactWebhookEventForStream(event)), + }) + .catch((err) => { + console.error("[webhooks/stream] live write failed", err); + }); + } + if (!advanced || batch.length < 500) { + break; + } + } + } while (liveDrainRequested && !aborted); + } catch (error) { + console.error("[webhooks/stream] live drain failed", error); + await stream.writeSSE({ + event: "stream-error", + data: JSON.stringify({ + message: + error instanceof Error ? error.message : "Live drain failed", + }), + }); + } finally { + liveDraining = false; + if (liveDrainRequested && !aborted) { + void drainLiveTail(); + } + } + }; + + let unsubscribe: (() => void) | null = null; + try { + unsubscribe = reactive.onUpdate( + api.webhooks.query.latestWebhookEventsSince, + { + apiKey, + sinceMs: liveStart - PHASE_OVERLAP_MS, + limit: 500, + }, + (events: unknown) => { + if (aborted) return; + if (!Array.isArray(events)) return; + void drainLiveTail(); + }, + ); + } catch (error) { + console.error("[webhooks/stream] subscribe failed", error); + await stream.writeSSE({ + event: "stream-error", + data: JSON.stringify({ + message: error instanceof Error ? error.message : "Subscribe failed", + }), + }); + // unsubscribe() not needed — onUpdate threw before returning a + // handle. Don't close the shared client. + return; + } + + await drainLiveTail(); + + // Phase 2 onUpdate is now armed — switch the dedup tracker + // from "hold every drained id" to "bounded sliding window." + // The pre-drain ids that are older than the overlap window + // (5s back from liveStart) can no longer be re-surfaced by + // the live tail, so they're safe to age out. + seen.markDrainComplete(); + + try { + while (!aborted) { + await stream.sleep(HEARTBEAT_MS); + if (aborted) break; + await drainLiveTail(); + if (aborted) break; + await stream.writeSSE({ event: "heartbeat", data: "" }); + } + } finally { + // Unsubscribe from this connection's onUpdate but DO NOT close + // the shared reactive client — other live SSE subscribers and + // future connections share the same WebSocket. + try { + unsubscribe?.(); + } catch { + // Some Convex client versions throw on double-unsubscribe + // during hot-reload paths; benign. + } + } + }); +}); + +// Translate an EventSource `Last-Event-ID` (which is the spec's stable +// `sourceNotificationId`) into a `sinceMs` + `afterCreationTime` cursor +// pair. The new `findEventCursor` query hits the dedicated +// `by_project_and_notification_id` index so the lookup is O(log n) +// regardless of how many events the project has accumulated. The prior +// implementation scanned the first 500 events and silently fell back +// to "now" for anything beyond that — projects with > 500 events +// would lose every replay-on-reconnect (PR #124 (https://github.com/hyodotdev/openiap/pull/124) review fix). +// +// Returns `{ sinceMs, afterCreationTime }` so the SSE handler can pass +// both to `webhookEventsSince` and resume strictly past the last +// emitted event even under same-`receivedAt` bursts. +async function resolveStreamStartCursor( + apiKey: string, + lastEventId: string | undefined, +): Promise<{ sinceMs: number; afterCreationTime?: number }> { + if (!lastEventId) { + // New client (no Last-Event-ID) starts from the live tail. The + // prior `sinceMs: 0` made every fresh connection drain the + // entire 30-day retention window, which on busy projects melted + // the kit pod and saturated the SDK with already-known history. + // Long-offline reconciliation is the explicit job of the + // `webhookEventsSince` query — clients that need historical + // events query it directly with whatever cursor they tracked. + return { sinceMs: Date.now() }; + } + try { + const match = await client.query(api.webhooks.query.findEventCursor, { + apiKey, + sourceNotificationId: lastEventId, + }); + if (match) { + return { + sinceMs: match.receivedAt, + afterCreationTime: match._creationTime, + }; + } + // Unknown lastEventId — never replay the full 30-day window for a + // confused / forged client. + return { sinceMs: Date.now() }; + } catch (error) { + const sanitized = + error instanceof Error + ? `${error.name}: ${error.message}` + : "(unknown error type)"; + console.warn("[webhooks/stream] cursor resolution failed", sanitized); + return { sinceMs: Date.now() }; + } +} + +const oauth2Client = new OAuth2Client(); + +async function verifyPubSubOidcToken( + authHeader: string | undefined, + audience: string, +): Promise { + if (!authHeader?.startsWith("Bearer ")) { + return false; + } + const token = authHeader.slice(7); + try { + const ticket = await oauth2Client.verifyIdToken({ + idToken: token, + audience, + }); + const payload = ticket.getPayload(); + if (!payload) { + return false; + } + const email = payload.email; + if (!email || payload.email_verified !== true) { + return false; + } + // Bind to a specific service-account principal when configured. + // Without GOOGLE_PUBSUB_PUSH_PRINCIPAL set we still enforce the + // gcp-sa-pubsub namespace so any project's Pub/Sub push could in + // theory hit our endpoint — operators in shared GCP orgs should + // pin GOOGLE_PUBSUB_PUSH_PRINCIPAL to their dedicated push SA. + const principal = process.env.GOOGLE_PUBSUB_PUSH_PRINCIPAL; + if (principal) { + return email === principal; + } + return email.endsWith("@gcp-sa-pubsub.iam.gserviceaccount.com"); + } catch (error) { + const sanitized = + error instanceof Error + ? `${error.name}: ${error.message}` + : "(unknown error type)"; + console.warn("[webhooks/google] OIDC verification error", sanitized); + return false; + } +} + +function mapWebhookError( + c: Context, + error: unknown, + source: "apple" | "google", +) { + const convexError = handleConvexError(error); + if (convexError !== null) { + // Apple/Google ship new notification types ahead of the openiap + // spec. Acknowledge with 200 so the upstream stops retrying — the + // event was deliberately dropped, not lost. Other normalization + // errors (MissingNotificationId, MissingPurchaseToken, + // BUNDLE_ID_MISMATCH, INVALID_SIGNATURE, …) are permanent + // configuration/payload errors that need 4xx so the operator + // notices and the upstream stops retrying. + if (convexError.code === "UNSUPPORTED_EVENT") { + return c.json({ ok: true, dropped: true, reason: convexError.message }); + } + // Bad / unrecognized API key: 401 with a stable code the dashboard + // and SDK can branch on without parsing the message. + if (convexError.code === "INVALID_API_KEY") { + return c.json({ errors: [convexError] }, 401); + } + // Per-platform setup-status gates (the action throws these when + // the project hasn't configured the matching platform). 412 + // Precondition Failed is the same status the previous HTTP-layer + // pre-check returned, so SDKs / dashboards branching on these + // codes don't have to change. + if ( + convexError.code === "IOS_NOT_CONFIGURED" || + convexError.code === "ANDROID_NOT_CONFIGURED" + ) { + return c.json({ errors: [convexError] }, 412); + } + return c.json({ errors: [convexError] }, 400); + } + + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.startsWith("UNSUPPORTED_EVENT")) { + // Legacy fallback — kept until all action paths migrate to the + // ConvexError shape above. + return c.json({ ok: true, dropped: true, reason: errorMessage }); + } + + console.error( + `[webhooks/${source}] unexpected error`, + errorMessage, + error instanceof Error ? error.stack : "", + ); + return c.json( + { + errors: [ + { + code: "WEBHOOK_INTERNAL_ERROR", + message: errorMessage, + }, + ], + }, + 500, + ); +} + +export { webhooks as webhooksRoutes }; diff --git a/packages/kit/server/convex.ts b/packages/kit/server/convex.ts index 0aca7db0..0482bc16 100644 --- a/packages/kit/server/convex.ts +++ b/packages/kit/server/convex.ts @@ -13,6 +13,12 @@ if (!convexUrl) { } export const client = new ConvexHttpClient(convexUrl); +// Used by the SSE webhook stream to subscribe to live query updates +// instead of polling. The reactive client is exported lazily so unit +// tests that only need `client` (the HTTP client) don't pay for a +// WebSocket dial when no subscription is opened. +export const convexUrlForRealtime = convexUrl; + interface ApiError { code: string; message: string; @@ -27,12 +33,46 @@ export function handleConvexError(error: unknown): ApiError | null { } function getConvexError(error: ConvexError): ApiError | null { + // Structured object-shaped error — the mutation/action threw + // `new ConvexError({ code, message })`. Convex preserves the object + // shape across the wire (data isn't always a string). + if (typeof error.data === "object" && error.data !== null) { + const objectResult = v.safeParse( + v.object({ + code: v.string(), + message: v.string(), + }), + error.data, + ); + if (objectResult.success) { + return { + code: objectResult.output.code, + message: objectResult.output.message, + }; + } + // Fall through to legacy `{ error, message }` shape for backward + // compat with any callers that didn't migrate to `{ code, message }`. + const legacyResult = v.safeParse( + v.object({ + error: v.string(), + message: v.string(), + }), + error.data, + ); + if (legacyResult.success) { + return { + code: legacyResult.output.error, + message: legacyResult.output.message, + }; + } + return null; + } + if (typeof error.data !== "string") { return null; } - // Structured error — the mutation/action threw - // `new ConvexError(JSON.stringify({ error, message }))`. + // Legacy structured error — `new ConvexError(JSON.stringify({ error, message }))`. try { const data = JSON.parse(error.data); diff --git a/packages/kit/src/components/Dropdown.tsx b/packages/kit/src/components/Dropdown.tsx index d9cb25cd..0ac11fbe 100644 --- a/packages/kit/src/components/Dropdown.tsx +++ b/packages/kit/src/components/Dropdown.tsx @@ -6,8 +6,10 @@ interface DropdownOption { label: string; } -interface DropdownProps - extends Omit, "className"> { +interface DropdownProps extends Omit< + SelectHTMLAttributes, + "className" +> { options: DropdownOption[]; className?: string; } diff --git a/packages/kit/src/components/FreeTransitionNotice.test.tsx b/packages/kit/src/components/FreeTransitionNotice.test.tsx index b32b96d7..dd0608b3 100644 --- a/packages/kit/src/components/FreeTransitionNotice.test.tsx +++ b/packages/kit/src/components/FreeTransitionNotice.test.tsx @@ -1,5 +1,5 @@ /** @vitest-environment jsdom */ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { render, screen, cleanup } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; diff --git a/packages/kit/src/convex.ts b/packages/kit/src/convex.ts index 7ca5831e..2da158d5 100644 --- a/packages/kit/src/convex.ts +++ b/packages/kit/src/convex.ts @@ -2,7 +2,7 @@ // Backend lives in ./convex (relative to repo root). export { api } from "../convex/_generated/api"; -export type { Id } from "../convex/_generated/dataModel"; +export type { Id, Doc } from "../convex/_generated/dataModel"; export { SUBSCRIPTION_PLANS } from "../convex/plans"; export type { SubscriptionPlanId } from "../convex/plans"; export { HarmonizedPurchaseState } from "../convex/purchases/purchaseState"; diff --git a/packages/kit/src/pages/auth/index.tsx b/packages/kit/src/pages/auth/index.tsx index 59ecb13f..6734981f 100644 --- a/packages/kit/src/pages/auth/index.tsx +++ b/packages/kit/src/pages/auth/index.tsx @@ -13,6 +13,9 @@ import OrganizationSettings from "./organization/settings"; import ProjectIndex from "./organization/project"; import ProjectPurchases from "./organization/project/purchases"; import ProjectApiKeys from "./organization/project/apikeys"; +import ProjectSubscriptions from "./organization/project/subscriptions"; +import ProjectProducts from "./organization/project/products"; +import ProjectWebhooks from "./organization/project/webhooks"; import ProjectSettings from "./organization/project/settings"; import ProjectPurchaseDetail from "./organization/project/purchase-detail"; import OrganizationUsagePage from "./organization/usage"; @@ -260,6 +263,30 @@ export default function AuthenticatedPages() { } /> + + + + } + /> + + + + } + /> + + + + } + /> @@ -179,7 +179,7 @@ export function MobileSidebar({ -