);
}
@@ -336,6 +336,7 @@ function EcosystemDiagram() {
+ and for every framework library, and the core
packages are bundled into each library. IAPKit is the
optional hosted layer for purchase verification, entitlements, store
- notifications, and product operations. Select any node to open its
+ notifications, and product operations, and the core packages can use it
+ directly without a framework library. Select any node to open its
documentation or project.
diff --git a/packages/docs/src/pages/docs/setup/store/amazon.tsx b/packages/docs/src/pages/docs/setup/store/amazon.tsx
index 2183a3e97..f92ce77bf 100644
--- a/packages/docs/src/pages/docs/setup/store/amazon.tsx
+++ b/packages/docs/src/pages/docs/setup/store/amazon.tsx
@@ -359,7 +359,7 @@ dotnet build -f net10.0-android -p:OpenIapAndroidStore=amazon`}
Fire OS and Vega OS both use the{' '}
IAPKit Amazon payload. Pass the
- Amazon user id when available, the Amazon receipt id, and{' '}
+ Amazon user id (required), the Amazon receipt id, and{' '}
expectedProductId for server-side product binding. For
Amazon App Tester, first enable{' '}
Allow Amazon App Tester / RVS Cloud Sandbox in the
diff --git a/packages/docs/src/pages/docs/updates/announcements.tsx b/packages/docs/src/pages/docs/updates/announcements.tsx
index a413672fa..b848aea9d 100644
--- a/packages/docs/src/pages/docs/updates/announcements.tsx
+++ b/packages/docs/src/pages/docs/updates/announcements.tsx
@@ -256,6 +256,7 @@ function Announcements() {
@@ -430,6 +431,7 @@ function Announcements() {
@@ -735,6 +737,7 @@ function Announcements() {
@@ -838,6 +841,7 @@ function Announcements() {
diff --git a/packages/docs/src/pages/docs/updates/migration.tsx b/packages/docs/src/pages/docs/updates/migration.tsx
index 970e1afc9..d683d76f1 100644
--- a/packages/docs/src/pages/docs/updates/migration.tsx
+++ b/packages/docs/src/pages/docs/updates/migration.tsx
@@ -38,12 +38,15 @@ const migrationGroups = [
],
[
'checkAlternativeBillingAvailabilityAndroid',
- "isBillingProgramAvailableAndroid with the 'external-offer' BillingProgramAndroid value",
+ 'isBillingProgramAvailableAndroid with the BillingProgramAndroid value your app is enrolled in',
+ ],
+ [
+ 'showAlternativeBillingDialogAndroid',
+ 'showBillingProgramInformationDialogAndroid (the in-app Billing Programs dialog); launchExternalLinkAndroid covers the external-link flows (External Offer, External Content Link, Billing Choice external links)',
],
- ['showAlternativeBillingDialogAndroid', 'launchExternalLinkAndroid'],
[
'createAlternativeBillingTokenAndroid',
- "createBillingProgramReportingDetailsAndroid with the 'external-offer' BillingProgramAndroid value",
+ 'createBillingProgramReportingDetailsAndroid with the BillingProgramAndroid value your app is enrolled in',
],
],
},
diff --git a/packages/docs/src/styles/base.css b/packages/docs/src/styles/base.css
index d6dc4b6b4..b3af3bdd3 100644
--- a/packages/docs/src/styles/base.css
+++ b/packages/docs/src/styles/base.css
@@ -88,6 +88,13 @@ img[src='/sponsors/meta.webp'] {
height: 2.75rem;
}
+/* Wrapped onto its own line above the title, the tiny banner reads as noise. */
+@media (max-width: 640px) {
+ .announcement-thumb {
+ display: none;
+ }
+}
+
img[src='/frameworks/apple.svg'] {
filter: var(--apple-logo-filter);
}
diff --git a/packages/docs/src/styles/ecosystem-diagram.css b/packages/docs/src/styles/ecosystem-diagram.css
index 6dd0a955f..48e3443c9 100644
--- a/packages/docs/src/styles/ecosystem-diagram.css
+++ b/packages/docs/src/styles/ecosystem-diagram.css
@@ -370,9 +370,13 @@
/* ---------- hosted infrastructure --------------------------------------- */
+.eco-rail--iapkit,
+.eco-rail--iapkit-core {
+ opacity: 0.72;
+}
+
.eco-rail--iapkit {
height: 42px;
- opacity: 0.72;
}
/* ---------- artwork -------------------------------------------------------
@@ -463,6 +467,12 @@
display: none;
}
+/* Stacked, there is no second channel beside Core -> Libraries -> IAPKit to
+ draw this in, so the figcaption is what states it there. */
+.eco-rail--iapkit-core {
+ display: none;
+}
+
.eco-rail-label {
position: relative;
z-index: 1;
@@ -601,8 +611,17 @@
grid-row: 7;
}
+ /* Core reaches IAPKit without a framework library. Spanning the filler row
+ keeps the line attached to the Core band however tall Libraries grows. */
+ .eco-rail--iapkit-core {
+ display: flex;
+ grid-column: 1;
+ grid-row: 6 / 8;
+ height: auto;
+ }
+
.eco-band--iapkit {
- grid-column: 3;
+ grid-column: 1 / -1;
grid-row: 8;
}
diff --git a/packages/kit/CONVENTION.md b/packages/kit/CONVENTION.md
index 3eb7e5ebd..e3f345bce 100644
--- a/packages/kit/CONVENTION.md
+++ b/packages/kit/CONVENTION.md
@@ -10,6 +10,20 @@ Convex schema as the source of truth for purchase-validation models.
For setup, operations, and deploy details, see [`README.md`](./README.md).
+## Production Is Read-Only For Agents
+
+`healthy-kudu-836` is the production deployment and holds real customer data.
+Never run a mutation or action against it — not from the Convex dashboard
+function runner, not from `npx convex run --prod`, not from anywhere. The
+dashboard runner reopens with the last function selected, which has included
+`drainAccountDeletionBatch`; check what is selected before running anything.
+
+Reads are fine when asked for. Report counts and aggregates rather than copying
+customer emails or other personal data anywhere. Use the dev deployment for
+anything that needs a new function.
+
+See the root `AGENTS.md` for the full guardrail.
+
## Naming
- **Brand name in user-facing text/titles**: `IAPKit` (no space).
diff --git a/packages/kit/convex/auth.ts b/packages/kit/convex/auth.ts
index 8d892ce3d..ec1bf7bc2 100644
--- a/packages/kit/convex/auth.ts
+++ b/packages/kit/convex/auth.ts
@@ -6,6 +6,11 @@ import {
} from "./ResendOTP";
import GitHub, { type GitHubProfile } from "@auth/core/providers/github";
import { api, internal } from "./_generated/api";
+import {
+ assertEmailSignInWindowOpen,
+ assertLegacyEmailAccount,
+ isResendProviderId,
+} from "./authWindow";
const CustomAuth = convexAuth({
providers: [
@@ -28,6 +33,8 @@ const CustomAuth = convexAuth({
],
callbacks: {
async createOrUpdateUser(ctx, args) {
+ assertEmailSignInWindowOpen(args.provider.id);
+
// Check if user exists with the same email
const email = args.profile.email;
const profileName =
@@ -56,6 +63,16 @@ const CustomAuth = convexAuth({
);
if (existingUser) {
+ // The UI gate (canSignInWithEmail) mirrors this; the boundary enforces it.
+ if (isResendProviderId(args.provider.id)) {
+ assertLegacyEmailAccount(
+ args.provider.id,
+ await ctx.runQuery(internal.users.internal.hasLegacyEmailAccount, {
+ userId: existingUser._id,
+ }),
+ );
+ }
+
// User exists - update auth user
const userId = existingUser._id;
@@ -93,7 +110,7 @@ const CustomAuth = convexAuth({
// is created. OAuth providers (github) are exempt — that's the
// path we want new users on.
const providerId = args.provider.id;
- const isResendProvider = providerId.startsWith("resend-otp");
+ const isResendProvider = isResendProviderId(providerId);
if (isResendProvider) {
throw new Error(
"New email signups are disabled. Please sign in with GitHub instead.",
diff --git a/packages/kit/convex/authWindow.test.ts b/packages/kit/convex/authWindow.test.ts
new file mode 100644
index 000000000..fef5d8a7b
--- /dev/null
+++ b/packages/kit/convex/authWindow.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+import {
+ EMAIL_SIGN_IN_CLOSES_AT,
+ EMAIL_SIGN_IN_CLOSES_ON,
+ assertEmailSignInWindowOpen,
+ assertLegacyEmailAccount,
+ isEmailSignInOpen,
+ isResendProviderId,
+} from "./authWindow";
+
+describe("email sign-in grace period", () => {
+ it("names the same day the timestamp encodes", () => {
+ expect(new Date(EMAIL_SIGN_IN_CLOSES_AT).toISOString()).toContain(
+ EMAIL_SIGN_IN_CLOSES_ON,
+ );
+ });
+
+ it("stays open through the whole closing day in UTC", () => {
+ expect(isEmailSignInOpen(Date.UTC(2026, 8, 30, 0, 0, 0, 0))).toBe(true);
+ expect(isEmailSignInOpen(EMAIL_SIGN_IN_CLOSES_AT)).toBe(true);
+ });
+
+ it("closes at the first moment of the next day", () => {
+ expect(isEmailSignInOpen(EMAIL_SIGN_IN_CLOSES_AT + 1)).toBe(false);
+ expect(isEmailSignInOpen(Date.UTC(2026, 9, 1, 0, 0, 0, 0))).toBe(false);
+ });
+});
+
+describe("email sign-in gates", () => {
+ it("recognizes every resend provider id and nothing else", () => {
+ expect(isResendProviderId("resend-otp-en")).toBe(true);
+ expect(isResendProviderId("resend-otp-ko")).toBe(true);
+ expect(isResendProviderId("github")).toBe(false);
+ });
+
+ it("lets email sign-in through while the window is open", () => {
+ expect(() =>
+ assertEmailSignInWindowOpen("resend-otp-en", EMAIL_SIGN_IN_CLOSES_AT),
+ ).not.toThrow();
+ });
+
+ it("rejects email sign-in after the window closes", () => {
+ expect(() =>
+ assertEmailSignInWindowOpen("resend-otp-en", EMAIL_SIGN_IN_CLOSES_AT + 1),
+ ).toThrow(/closed on 2026-09-30 \(UTC\)/);
+ });
+
+ it("never blocks GitHub, even after the window closes", () => {
+ expect(() =>
+ assertEmailSignInWindowOpen("github", EMAIL_SIGN_IN_CLOSES_AT + 1),
+ ).not.toThrow();
+ });
+
+ it("accepts OTP for accounts that already used email", () => {
+ expect(() => assertLegacyEmailAccount("resend-otp-en", true)).not.toThrow();
+ });
+
+ it("rejects OTP for GitHub-created accounts", () => {
+ expect(() => assertLegacyEmailAccount("resend-otp-en", false)).toThrow(
+ /continue with GitHub/i,
+ );
+ });
+});
diff --git a/packages/kit/convex/authWindow.ts b/packages/kit/convex/authWindow.ts
new file mode 100644
index 000000000..dbe0fde85
--- /dev/null
+++ b/packages/kit/convex/authWindow.ts
@@ -0,0 +1,45 @@
+// Sunset for the Resend OTP provider. New signups have been GitHub-only since
+// 2026-04; this is the grace period in which the remaining email-only accounts
+// can still sign in and get merged onto GitHub by matching email.
+//
+// Not a Convex function module — plain constants shared by auth.ts and
+// users/query.ts so the cutoff is written down once.
+
+export const EMAIL_SIGN_IN_CLOSES_ON = "2026-09-30";
+
+// Inclusive of the whole closing day, in UTC.
+export const EMAIL_SIGN_IN_CLOSES_AT = Date.UTC(2026, 8, 30, 23, 59, 59, 999);
+
+export function isEmailSignInOpen(now: number = Date.now()): boolean {
+ return now <= EMAIL_SIGN_IN_CLOSES_AT;
+}
+
+export function isResendProviderId(providerId: string): boolean {
+ return providerId.startsWith("resend-otp");
+}
+
+// Grace period, then GitHub-only. Existing email accounts merge onto GitHub
+// by matching email, so closing costs access only when the emails differ.
+export function assertEmailSignInWindowOpen(
+ providerId: string,
+ now: number = Date.now(),
+): void {
+ if (isResendProviderId(providerId) && !isEmailSignInOpen(now)) {
+ throw new Error(
+ `Email sign-in closed on ${EMAIL_SIGN_IN_CLOSES_ON} (UTC). Sign in with GitHub using the same email address.`,
+ );
+ }
+}
+
+// Email OTP is only for accounts that already used it; a GitHub-created
+// account keeps using GitHub even while the window is open.
+export function assertLegacyEmailAccount(
+ providerId: string,
+ hasLegacyEmailAccount: boolean,
+): void {
+ if (isResendProviderId(providerId) && !hasLegacyEmailAccount) {
+ throw new Error(
+ "This account uses GitHub sign-in. Please continue with GitHub.",
+ );
+ }
+}
diff --git a/packages/kit/convex/purchases/ios.test.ts b/packages/kit/convex/purchases/ios.test.ts
index d6e61891b..6539bc327 100644
--- a/packages/kit/convex/purchases/ios.test.ts
+++ b/packages/kit/convex/purchases/ios.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
-import { recordAppStoreVerifiedSubscription } from "./ios";
+import {
+ assertVerifiedTransactionBinding,
+ recordAppStoreVerifiedSubscription,
+} from "./ios";
import {
applyExpectedProductId,
AppStoreProductType,
@@ -252,3 +255,55 @@ describe("recordAppStoreVerifiedSubscription", () => {
expect(calls).toHaveLength(0);
});
});
+
+describe("assertVerifiedTransactionBinding", () => {
+ const verified = {
+ transactionId: "2000001177054625",
+ bundleId: "dev.hyo.martie",
+ environment: "Production" as const,
+ };
+
+ it("accepts a response matching the request on all three fields", () => {
+ expect(() =>
+ assertVerifiedTransactionBinding({
+ requestedTransactionId: "2000001177054625",
+ requestedEnvironment: "Production",
+ expectedBundleId: "dev.hyo.martie",
+ verified,
+ }),
+ ).not.toThrow();
+ });
+
+ it("rejects a transaction id drift", () => {
+ expect(() =>
+ assertVerifiedTransactionBinding({
+ requestedTransactionId: "9999999999999999",
+ requestedEnvironment: "Production",
+ expectedBundleId: "dev.hyo.martie",
+ verified,
+ }),
+ ).toThrow(/transactionId/);
+ });
+
+ it("rejects a bundle id that is not the project's", () => {
+ expect(() =>
+ assertVerifiedTransactionBinding({
+ requestedTransactionId: "2000001177054625",
+ requestedEnvironment: "Production",
+ expectedBundleId: "dev.other.app",
+ verified,
+ }),
+ ).toThrow(/bundleId/);
+ });
+
+ it("rejects an environment drift", () => {
+ expect(() =>
+ assertVerifiedTransactionBinding({
+ requestedTransactionId: "2000001177054625",
+ requestedEnvironment: "Sandbox",
+ expectedBundleId: "dev.hyo.martie",
+ verified,
+ }),
+ ).toThrow(/environment/);
+ });
+});
diff --git a/packages/kit/convex/purchases/ios.ts b/packages/kit/convex/purchases/ios.ts
index 4b8150b32..c37a96ca1 100644
--- a/packages/kit/convex/purchases/ios.ts
+++ b/packages/kit/convex/purchases/ios.ts
@@ -131,6 +131,13 @@ export const verifyAppStoreReceiptInternalV1 = action({
throw error;
}
+ assertVerifiedTransactionBinding({
+ requestedTransactionId: decodedPayload.transactionId,
+ requestedEnvironment: environment,
+ expectedBundleId: project.iosBundleId,
+ verified: transactionData,
+ });
+
const remoteId =
transactionData.originalTransactionId ||
transactionData.transactionId ||
@@ -345,6 +352,41 @@ export async function getAppStoreServerCredentials(
};
}
+// The device JWS is decode-only (its claims are attacker-writable); Apple's
+// response is what SignedDataVerifier proves. Reject any drift between the
+// two so a tampered payload cannot select another transaction's verdict.
+export function assertVerifiedTransactionBinding(params: {
+ requestedTransactionId: unknown;
+ requestedEnvironment: string;
+ expectedBundleId: string;
+ verified: Pick<
+ AppStoreReceiptData,
+ "transactionId" | "bundleId" | "environment"
+ >;
+}): void {
+ const { requestedTransactionId, requestedEnvironment, expectedBundleId } =
+ params;
+ const verified = params.verified;
+
+ if (verified.transactionId !== requestedTransactionId) {
+ throw new AppStoreTransactionVerificationFailedError(
+ `verified transactionId ${String(verified.transactionId)} does not match the requested ${String(requestedTransactionId)}`,
+ );
+ }
+
+ if (verified.bundleId !== expectedBundleId) {
+ throw new AppStoreTransactionVerificationFailedError(
+ `verified bundleId ${String(verified.bundleId)} does not match the project's ${expectedBundleId}`,
+ );
+ }
+
+ if (verified.environment !== requestedEnvironment) {
+ throw new AppStoreTransactionVerificationFailedError(
+ `verified environment ${String(verified.environment)} does not match the requested ${requestedEnvironment}`,
+ );
+ }
+}
+
async function verifyTransactionWithServerApi(params: {
ctx: ActionCtx;
decodedJwsPayload: JWSTransactionDecodedPayload;
diff --git a/packages/kit/convex/purchases/retry.ts b/packages/kit/convex/purchases/retry.ts
index f035828bd..be8da133b 100644
--- a/packages/kit/convex/purchases/retry.ts
+++ b/packages/kit/convex/purchases/retry.ts
@@ -135,7 +135,7 @@ export async function retryOnTransient(
const exponent = attempt - 1;
const raw = baseDelayMs * Math.pow(2, exponent);
const capped = Math.min(raw, maxDelayMs);
- // Full jitter in [0.5, 1.0) of the capped delay — smooths retry
+ // Jitter in [0.5, 1.0) of the capped delay — smooths retry
// bursts without extending worst-case wait beyond the cap.
const jittered = capped * (0.5 + Math.random() * 0.5);
await sleep(jittered);
diff --git a/packages/kit/convex/users/internal.test.ts b/packages/kit/convex/users/internal.test.ts
new file mode 100644
index 000000000..c21679665
--- /dev/null
+++ b/packages/kit/convex/users/internal.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { RESEND_PROVIDER_IDS, hasAnyResendAccount } from "./internal";
+
+describe("hasAnyResendAccount", () => {
+ it("finds an account on the first provider", async () => {
+ await expect(
+ hasAnyResendAccount(async (provider) =>
+ provider === "resend-otp-en" ? { _id: "a" } : null,
+ ),
+ ).resolves.toBe(true);
+ });
+
+ it("keeps looking past earlier providers", async () => {
+ const asked: string[] = [];
+ await expect(
+ hasAnyResendAccount(async (provider) => {
+ asked.push(provider);
+ return provider === "resend-otp-ja" ? { _id: "a" } : null;
+ }),
+ ).resolves.toBe(true);
+ expect(asked).toEqual([...RESEND_PROVIDER_IDS]);
+ });
+
+ it("returns false when no provider has an account", async () => {
+ await expect(hasAnyResendAccount(async () => null)).resolves.toBe(false);
+ });
+});
diff --git a/packages/kit/convex/users/internal.ts b/packages/kit/convex/users/internal.ts
index af25d5c95..df58a2de3 100644
--- a/packages/kit/convex/users/internal.ts
+++ b/packages/kit/convex/users/internal.ts
@@ -13,6 +13,36 @@ export const findByEmail = internalQuery({
},
});
+// Grace-period gate: email OTP is only for accounts that already used it.
+// A GitHub-created account must keep using GitHub even before the cutoff.
+export const RESEND_PROVIDER_IDS = [
+ "resend-otp-en",
+ "resend-otp-ko",
+ "resend-otp-ja",
+] as const;
+
+export async function hasAnyResendAccount(
+ findAccount: (provider: string) => Promise
- {/* Divider + email-legacy escape hatch. Kept low-key so new
- users gravitate toward GitHub, while the ~110 existing
- email-only accounts still have an obvious path in. */}
-
-
-
-
-
-
- {"or"}
-
-
-
+ {/* Divider + email-legacy escape hatch, shown only while the
+ grace period is open. After it closes the server rejects
+ resend-otp outright, so offering the link would dead-end. */}
+ {emailSignInOpen && (
+ <>
+
+
+
+
+
+
+ {"or"}
+
+
+
-
+
+
+
+ {`Email sign-in ends ${EMAIL_SIGN_IN_CLOSES_ON} (UTC). After that IAPKit supports GitHub sign-in only — sign in with GitHub using the same email address and your account carries over.`}
+
Observability into purchase flows — see exactly where payments
break.
diff --git a/packages/kit/src/pages/docs/DocsLayout.tsx b/packages/kit/src/pages/docs/DocsLayout.tsx
index 7f4040618..b90c8db91 100644
--- a/packages/kit/src/pages/docs/DocsLayout.tsx
+++ b/packages/kit/src/pages/docs/DocsLayout.tsx
@@ -194,6 +194,7 @@ function DocsNavRow({
-
~3 KB
+
~14 KB
@@ -68,19 +68,19 @@ export default function AiAssistantsPage() {
table, error body shape, structured log line, retry policy,
Sentry config, Convex data model, deployment.
-
~9 KB
+
~25 KB
-
+
- The IAPKit repository is private, which normally prevents
- code-assistants from reasoning about it. Serving the reference as
- plain text at a stable URL means any LLM-powered editor (Claude Code,
- Cursor, Zed, Continue, etc.) that supports URL loaders can still pull
- in accurate context without repo access.
+ IAPKit is open source, but pointing an assistant at the monorepo costs
+ a lot of tokens to answer a one-line API question. Serving a condensed
+ reference as plain text at a stable URL lets any LLM-powered editor
+ (Claude Code, Cursor, Zed, Continue, etc.) that supports URL loaders
+ pull accurate context in one fetch.
@@ -105,7 +105,7 @@ export default function AiAssistantsPage() {
>
Claude Code plugin guide
{" "}
- for the setup flow, self-hosted option, and tool list.
+ for the IAPKit endpoint and key details.
Using the files
diff --git a/packages/kit/src/pages/docs/sections/api.tsx b/packages/kit/src/pages/docs/sections/api.tsx
index f1a2abd3a..3b499787c 100644
--- a/packages/kit/src/pages/docs/sections/api.tsx
+++ b/packages/kit/src/pages/docs/sections/api.tsx
@@ -144,8 +144,8 @@ export default function ApiReferencePage() {
- Grant or fulfill only when isValid === true, the harmonized
- state permits that operation, and the store-verified
+ Grant or fulfill only when isValid === true, the harmonized{" "}
+ state permits that operation, and the store-verified{" "}
productId is present and matches the product your app
expected. For Meta Horizon, productId is the SKU IAPKit
checked. Amazon responses also identify the server-selected{" "}
@@ -433,10 +433,12 @@ async function refreshEntitlements(
originalTransactionId explicitly.
- Administrative subscription endpoints use{" "}
- Authorization: Bearer openiap-kit_sk_.... Compatibility
- routes with a key in the path remain available, but new server-side and
- MCP integrations should keep secret keys out of URLs.
+ Administrative subscription endpoints require{" "}
+ Authorization: Bearer openiap-kit_sk_.... IAPKit never
+ accepts a secret key in a URL — a secret key in a path returns{" "}
+ 410 SECRET_API_KEY_IN_URL. The compatibility routes that
+ keep a key in the path accept publishable keys only, for SDK runtimes
+ that strip request headers.
{`{
@@ -450,8 +452,19 @@ async function refreshEntitlements(
that explicitly ask for a JWS. Do not log or publish JWS values.
-
-
+
+ state has two distinct vocabularies. The table below is the
+ verification vocabulary returned by /v1/purchase/verify.
+ The subscription snapshot endpoints use a lifecycle vocabulary instead —{" "}
+ Active, InGracePeriod,{" "}
+ InBillingRetry, Expired, Revoked,{" "}
+ Refunded, Paused, Unknown — so
+ gating a snapshot on state === "ENTITLED" never
+ matches.
+
+
+
+
State
@@ -523,6 +536,67 @@ async function refreshEntitlements(
+
Subscription endpoints
+
+ Bind first, then read. POST /v1/subscriptions/bind-user{" "}
+ associates a store transaction with your own user id; until a purchase
+ is bound, the read endpoints resolve userId against rows
+ that were never linked and return an empty snapshot.
+
+
+
+
+
+
Endpoint
+
Key
+
Notes
+
+
+
+
+
+ POST /v1/subscriptions/bind-user
+
+
Publishable
+
+ Links a purchase to your user id. Body up to 32 KB.
+
+
+
+
+ GET /v1/subscriptions/status
+
+
Publishable
+
+ Current snapshot for one userId (≤256 chars).
+ Supports ETag / If-None-Match.
+
+
+
+
+ GET /v1/subscriptions/entitlements
+
+
Publishable
+
+ Entitled product ids for one userId. Already
+ filtered to non-expired rows, so everything returned is
+ currently entitled.
+
+
+
+
+ GET /v1/subscriptions/list
+
+
Secret
+
+ Project-wide administrative listing. limit capped
+ at 200.
+
+
+
+
+
+
Response headers
Verification requests that pass bearer-token shape validation carry a
@@ -557,7 +631,7 @@ async function refreshEntitlements(
The OpenIAP plugin connects Claude Code to this IAPKit project through
- the hosted /mcp endpoint. Use this page for the Kit-local
+ the hosted /mcp endpoint. Use this page for the IAPKit
endpoint and key details; use the OpenIAP MCP Server guide for the full
installation flow, local PR testing, tool list, safety rules, and
Example App walkthrough.
diff --git a/packages/kit/src/pages/docs/sections/codex-plugin.tsx b/packages/kit/src/pages/docs/sections/codex-plugin.tsx
index 55cd582af..1e80c7c24 100644
--- a/packages/kit/src/pages/docs/sections/codex-plugin.tsx
+++ b/packages/kit/src/pages/docs/sections/codex-plugin.tsx
@@ -13,7 +13,7 @@ export default function CodexPluginPage() {
>
The OpenIAP Codex plugin connects Codex to this IAPKit project through
- the hosted /mcp endpoint. Use this page for the Kit-local
+ the hosted /mcp endpoint. Use this page for the IAPKit
endpoint and key details; use the OpenIAP MCP Server guide for the full
installation flow, local PR testing, tool list, safety rules, and
Example App walkthrough.
diff --git a/packages/kit/src/pages/docs/sections/compatibility.tsx b/packages/kit/src/pages/docs/sections/compatibility.tsx
index eee860a4c..15f1ef47c 100644
--- a/packages/kit/src/pages/docs/sections/compatibility.tsx
+++ b/packages/kit/src/pages/docs/sections/compatibility.tsx
@@ -62,7 +62,7 @@ X-OpenIAP-Spec: 3.2.0`}
Contract enforcement
- CI compares Kit's response enums with the OpenIAP schema used to
+ CI compares IAPKit's response enums with the OpenIAP schema used to
generate every SDK. Runtime response validation and SDK parser tests
then verify that unknown optional metadata degrades without weakening
required fields.
diff --git a/packages/kit/src/pages/docs/sections/introduction.tsx b/packages/kit/src/pages/docs/sections/introduction.tsx
index aed20caba..cb8e5affd 100644
--- a/packages/kit/src/pages/docs/sections/introduction.tsx
+++ b/packages/kit/src/pages/docs/sections/introduction.tsx
@@ -46,7 +46,7 @@ export default function IntroductionPage() {
IAPKit itself is the managed receipt-verification server. Your app can
call it directly with an openiap-kit_pk_ publishable key,
- so you do not need to build a proxy just to verify a purchase. Secret
+ so you do not need to build a proxy just to verify a purchase. Secret{" "}
openiap-kit_sk_ keys are only for administrative work
such as MCP, catalog or payload writes, analytics, and store sync. You
still need your own authenticated backend when resources on that
@@ -77,7 +77,7 @@ export default function IntroductionPage() {
}
title="Amazon Appstore"
- detail="Fire OS receipts verified and periodically refreshed through Amazon RVS. Cloud Sandbox is disabled by default and requires an explicit project opt-in."
+ detail="Fire OS and Vega OS receipts verified and periodically refreshed through Amazon RVS. Cloud Sandbox is disabled by default and requires an explicit project opt-in."
slug="verification/amazon"
/>
diff --git a/packages/kit/src/pages/docs/sections/operations.tsx b/packages/kit/src/pages/docs/sections/operations.tsx
index beee952bb..9ab6fd3aa 100644
--- a/packages/kit/src/pages/docs/sections/operations.tsx
+++ b/packages/kit/src/pages/docs/sections/operations.tsx
@@ -166,7 +166,7 @@ X-RateLimit-Remaining: 599`}
/health endpoint
GET /health returns public service, API contract version,
- deployment revision, environment, and response-time metadata without
+ deployment revision, environment, and a response timestamp without
hitting Convex or any external store. Point Fly.io readiness / liveness
probes at it; point your own uptime monitors at it too. The response
uses Cache-Control: no-store and remains intentionally
@@ -204,12 +204,13 @@ X-RateLimit-Remaining: 599`}
Outbound retries
- Calls to Google Play's Android Publisher API and Meta Graph API are
- wrapped in an exponential-backoff retry (max 3 attempts, base 200 ms,
- cap 2 s, full jitter) that fires on HTTP 5xx and Node network errors (
- ECONNRESET, ETIMEDOUT, EAI_AGAIN,
- …). 4xx responses — including 404 and 410, which are deterministic — are{" "}
- not retried.
+ Calls to Apple's App Store Server API, Google Play's Android Publisher
+ API, Amazon RVS, and the Meta Graph API are wrapped in an
+ exponential-backoff retry (max 3 attempts, base 200 ms, cap 2 s,
+ jittered to 50–100% of the capped delay) that fires on HTTP 5xx and Node
+ network errors (ECONNRESET, ETIMEDOUT,{" "}
+ EAI_AGAIN, …). 4xx responses — including 404 and 410, which
+ are deterministic — are not retried.
Sentry
diff --git a/packages/kit/src/pages/docs/sections/projects.tsx b/packages/kit/src/pages/docs/sections/projects.tsx
index 0c54976b4..a1642854c 100644
--- a/packages/kit/src/pages/docs/sections/projects.tsx
+++ b/packages/kit/src/pages/docs/sections/projects.tsx
@@ -39,17 +39,13 @@ export default function ProjectsPage() {
Creating a project
From the organization dashboard, open the Projects tab
- and click New project. You supply a display name and
- pick the client platform (React Native, Flutter, Kotlin Multiplatform,
- native iOS / Android, web, …). The platform tag is informational — it
- drives which setup guides the dashboard highlights; it doesn't affect
- the verify API itself.
+ and click Create Project. Enter a{" "}
+ Project Name, optionally edit the generated{" "}
+ Project URL slug, and optionally pick a{" "}
+ Platform or Language. The platform tag is informational
+ — it drives which setup guides the dashboard highlights; it doesn't
+ affect the verify API itself.
-
Store credentials
Each project's Settings tab has two store configuration cards:
@@ -154,7 +150,7 @@ export default function ProjectsPage() {
diff --git a/packages/kit/src/pages/docs/sections/quickstart.tsx b/packages/kit/src/pages/docs/sections/quickstart.tsx
index b7aabce71..f6fcfb8b0 100644
--- a/packages/kit/src/pages/docs/sections/quickstart.tsx
+++ b/packages/kit/src/pages/docs/sections/quickstart.tsx
@@ -14,7 +14,7 @@ export default function QuickstartPage() {
>
1. Create your account
- Sign in with GitHub or email OTP on{" "}
+ Sign in with GitHub on{" "}
kit.openiap.dev
- . The onboarding flow asks you to name your first organization before
- opening its dashboard. The hosted service is free under fair-use
- safeguards on shared community infrastructure; no plan or credit card is
- required.
+ . New accounts are created through GitHub. Accounts created before April
+ 2026 can still sign in with an email one-time code until{" "}
+ 2026-09-30 (UTC); after that IAPKit supports GitHub
+ sign-in only. Signing in with GitHub using the same email address
+ carries an existing account over. The onboarding flow asks you to name
+ your first organization before opening its dashboard. The hosted service
+ is free under fair-use safeguards on shared community infrastructure; no
+ plan or credit card is required.
+ React Native IAP and Expo IAP ship a kitApi helper so you
+ do not have to build these requests by hand — see the sample on{" "}
+
+ Products
+ {" "}
+ and the SDK matrix on{" "}
+
+ Compatibility
+
+ .
+
IAPKit verifies Amazon Appstore receipts through the Receipt
Verification Service (RVS). Your app sends the Amazon{" "}
- userId and receiptId; IAPKit selects the RVS
- environment and supplies the project's credential without exposing
- it to the app.
+ userId (required) and receiptId; IAPKit
+ selects the RVS environment and supplies the project's credential
+ without exposing it to the app.
Amazon settings live beside Google Play and Meta Horizon because Fire
OS apps use the Android project surface. Google Play configuration is
- not required for an Amazon-only project.
+ not required for an Amazon-only project. Vega OS apps send the same
+ userId / receiptId payload to the same endpoint and need no separate
+ configuration.
Apple verification uses a signed JWS transaction produced by StoreKit 2
- on the device. IAPKit verifies the signature against Apple's root CA,
- then calls the App Store Server API with your project's .p8{" "}
- key to pull the transaction's current state (refund, revocation, grace
- period). Both steps are required to catch refunds issued after the
- purchase.
+ on the device. IAPKit decodes the JWS to read its transaction id, bundle
+ id, and environment, then calls the App Store Server API with your
+ project's .p8 key and cryptographically verifies the signed
+ transaction Apple returns against Apple's root CA. The device's copy of
+ the JWS is only a lookup key — the authoritative record is the one Apple
+ signs in its response, which is what catches refunds and revocations
+ issued after the purchase.
What you'll need
@@ -87,9 +89,11 @@ export default function VerificationApplePage() {
IAPKit reads the JWS environment field off the decoded
payload, so the same project can verify both sandbox and production
- receipts without a toggle. Just make sure the App Apple ID is set
- before you go to production — the JWS signature is bound to it for
- production-environment receipts.
+ receipts without a toggle. Set the App Apple ID before you ship to
+ production — a production-environment JWS is rejected with{" "}
+ PROJECT_APP_STORE_APPLE_ID_NOT_CONFIGURED before IAPKit
+ contacts Apple, because Apple's verification library refuses to run in
+ the production environment without it. Sandbox does not need it.
@@ -111,11 +115,11 @@ export default function VerificationApplePage() {
How refunds are detected
- After verifying the JWS signature, IAPKit calls the App Store Server
- API's getTransactionInfo endpoint to fetch the current
- state of that transaction — signature-valid means "the purchase once
- happened"; getTransactionInfo tells you if it's still valid
- right now. No extra flag on the request is required.
+ IAPKit calls the App Store Server API's getTransactionInfo{" "}
+ endpoint to fetch the current state of that transaction — the device's
+ JWS only proves a purchase once existed; getTransactionInfo{" "}
+ tells you if it's still valid right now. No extra flag on the request is
+ required.
For an active transaction, the decoded JWS payload looks like:
diff --git a/packages/kit/src/pages/docs/sections/verification-google.tsx b/packages/kit/src/pages/docs/sections/verification-google.tsx
index e2458d46f..328a7409f 100644
--- a/packages/kit/src/pages/docs/sections/verification-google.tsx
+++ b/packages/kit/src/pages/docs/sections/verification-google.tsx
@@ -134,12 +134,31 @@ export default function VerificationGooglePage() {
Transient retries
Both v2 calls are wrapped in a 3-attempt exponential-backoff retry
- (200ms base, 2s cap, full jitter). The retry fires on HTTP 5xx and Node
- network errors (ECONNRESET, ETIMEDOUT,{" "}
- EAI_AGAIN, …). 4xx responses — including 404 ("not a
- product") and 410 ("token no longer valid") — are not{" "}
- retried because re-issuing the call won't help and would only waste
- quota.
+ (200ms base, 2s cap, jitter to 50–100% of the capped delay). The retry
+ fires on HTTP 5xx and Node network errors (ECONNRESET,{" "}
+ ETIMEDOUT, EAI_AGAIN, …). A 404 from the
+ product lookup is not an error — it just means the token is a
+ subscription, so IAPKit falls through to subscriptionsv2.
+ When neither catalog knows the token, IAPKit retries the whole pair up
+ to 3 times over roughly 750 ms, because a purchase verified within a
+ second of completing can still be propagating inside Play. Every other
+ 4xx response, including 410 ("token no longer valid"), is{" "}
+ not retried because re-issuing the call won't help and
+ would only waste quota.
+
+
+
+ Negative verdicts that return 200
+
+
+ Not every rejection is an error. A revoked or purged token (Play 410)
+ returns 200 with isValid: false and{" "}
+ state: UNKNOWN — Google returns the same 410 for a token it
+ never issued and for a subscription purged 60 days after expiry, so
+ IAPKit cannot distinguish them; do not retry it. A token that belongs to
+ a different package returns 200 with{" "}
+ isValid: false and state: INAUTHENTIC. Gate
+ entitlement on isValid, not on the HTTP status.
Error codes
@@ -162,20 +181,12 @@ export default function VerificationGooglePage() {
- PLAY_STORE_PURCHASE_NOT_FOUND
-
-
- Token doesn't resolve to a product or subscription — usually a
- replay or a subscription purged after 60 days of inactivity.
-
- Auth failure, permission mismatch, or Google returned a shape
- IAPKit couldn't interpret.
+ Every store-side failure after credentials load: token not
+ found, auth or permission failure, or a response IAPKit could
+ not interpret. The originating reason is in the message.
diff --git a/packages/kit/src/pages/faq.tsx b/packages/kit/src/pages/faq.tsx
new file mode 100644
index 000000000..f24d02b25
--- /dev/null
+++ b/packages/kit/src/pages/faq.tsx
@@ -0,0 +1,14 @@
+import { FAQSection } from "@/components/FAQSection";
+import faqContent from "@/content/faq.md?raw";
+import { parseFaqMarkdown } from "@/utils/faq";
+
+export default function FaqPage() {
+ return (
+
+ );
+}
diff --git a/packages/kit/src/pages/index.tsx b/packages/kit/src/pages/index.tsx
index fc5c0311e..f188862e4 100644
--- a/packages/kit/src/pages/index.tsx
+++ b/packages/kit/src/pages/index.tsx
@@ -14,6 +14,7 @@ import Terms from "./terms-of-service";
import Privacy from "./privacy-policy";
import About from "./about";
import Contact from "./contact";
+import Faq from "./faq";
import NotFound from "./404";
// Public Layout Component (for unauthenticated users)
@@ -73,6 +74,22 @@ export default function PublicPages() {
}
/>
+
+
+
+ }
+ />
}>
} />
} />
diff --git a/packages/kit/src/pages/landing.tsx b/packages/kit/src/pages/landing.tsx
index f7197375b..b9dc1f2b6 100644
--- a/packages/kit/src/pages/landing.tsx
+++ b/packages/kit/src/pages/landing.tsx
@@ -51,7 +51,7 @@ export default function LandingPage() {
style={{ lineHeight: "1.2" }}
>
- {"Open IAP foundation for your"}
+ {"OpenIAP foundation for your"}
{
- "We contact each supported store, verify authoritative purchase state, and flag risky transactions before you deliver the item."
+ "We contact each supported store, verify authoritative purchase state, and return one normalized verdict — isValid, state, and the store-verified productId — before you deliver the item."
}
diff --git a/packages/kit/src/utils/constants.ts b/packages/kit/src/utils/constants.ts
index 953a5a558..712b238ed 100644
--- a/packages/kit/src/utils/constants.ts
+++ b/packages/kit/src/utils/constants.ts
@@ -1,19 +1,7 @@
-// App info
-export const APP_NAME = "IAPKit";
-export const APP_URL = "https://openiap-kit.com";
-export const APP_DESCRIPTION = "Next-generation IAP integration solution";
-
-// Contact
export const SUPPORT_EMAIL = "hyo@hyo.dev";
-export const CONTACT_EMAIL = "hyo@hyo.dev";
-
-if (!SUPPORT_EMAIL || !CONTACT_EMAIL) {
- throw new Error("SUPPORT_EMAIL and CONTACT_EMAIL must be set");
-}
-// Social links
-export const SOCIAL_LINKS = {
- twitter: "https://twitter.com/openiap-kit",
- github: "https://github.com/openiap-kit",
- discord: "https://discord.gg/5AQd8BbxWT",
-};
+// The cutoff lives with its enforcement; the modal just renders it.
+export {
+ EMAIL_SIGN_IN_CLOSES_ON,
+ isEmailSignInOpen,
+} from "../../convex/authWindow";
diff --git a/packages/mcp-server/src/mcp.ts b/packages/mcp-server/src/mcp.ts
index a1fab866f..4d7312ad4 100644
--- a/packages/mcp-server/src/mcp.ts
+++ b/packages/mcp-server/src/mcp.ts
@@ -659,7 +659,7 @@ function registerIapKitTools(server: McpServer) {
registerTool(
server,
"revenue_analytics",
- "Summarize IAPKit subscription purchase and revenue analytics for a date range. Defaults to the current UTC month so Codex can answer questions like 'how many purchases happened this month?'.",
+ "Summarize IAPKit subscription purchase and revenue analytics for a date range. Defaults to the current UTC month so an assistant can answer questions like 'how many purchases happened this month?'.",
{
period: z
.enum(["this_month", "last_30_days", "last_90_days", "custom"])
@@ -847,7 +847,7 @@ function registerIapKitTools(server: McpServer) {
registerTool(
server,
"sync_products",
- "Enqueue an IAPKit product sync job for App Store Connect or Google Play. Use dryRun=true first to inspect what Codex would change; set dryRun=false only when the user explicitly asks to apply the store sync.",
+ "Enqueue an IAPKit product sync job for App Store Connect or Google Play. Use dryRun=true first to inspect what the sync would change; set dryRun=false only when the user explicitly asks to apply the store sync.",
{
platform: z.enum(["IOS", "Android"]),
direction: z
@@ -859,7 +859,7 @@ function registerIapKitTools(server: McpServer) {
dryRun: z
.boolean()
.optional()
- .describe("Defaults to true so Codex previews store changes first."),
+ .describe("Defaults to true so store changes are previewed first."),
apiKey: OPTIONAL_API_KEY,
baseUrl: OPTIONAL_BASE_URL,
},
diff --git a/plugins/openiap/.codex-plugin/plugin.json b/plugins/openiap/.codex-plugin/plugin.json
index 772b6c59a..f21e4d837 100644
--- a/plugins/openiap/.codex-plugin/plugin.json
+++ b/plugins/openiap/.codex-plugin/plugin.json
@@ -6,7 +6,7 @@
"name": "OpenIAP",
"url": "https://openiap.dev"
},
- "homepage": "https://kit.openiap.dev/docs/ai-assistants/codex-plugin",
+ "homepage": "https://openiap.dev/docs/guides/mcp-server",
"repository": "https://github.com/hyodotdev/openiap",
"license": "MIT",
"keywords": ["openiap", "iapkit", "in-app-purchases", "mcp", "codex"],
diff --git a/scripts/audit-agent-surfaces.mjs b/scripts/audit-agent-surfaces.mjs
new file mode 100644
index 000000000..d21cbb2e3
--- /dev/null
+++ b/scripts/audit-agent-surfaces.mjs
@@ -0,0 +1,158 @@
+#!/usr/bin/env node
+
+// Keeps Codex, Claude, and Grok pointed at the same workflows. Every surface is
+// discovered from disk, so adding a command or skill without registering it
+// everywhere fails here instead of silently working for one agent only.
+
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const scriptPath = fileURLToPath(import.meta.url);
+const repositoryRoot = path.resolve(path.dirname(scriptPath), "..");
+
+const COMMANDS_DIR = ".claude/commands";
+const CODEX_SKILLS_DIR = ".codex/skills";
+const CLAUDE_SKILLS_DIR = ".claude/skills";
+const CODEX_ROUTER = ".codex/skills/openiap-workflows/SKILL.md";
+const CLAUDE_ROUTER = ".claude/skills/openiap-workflows/SKILL.md";
+const INSTRUCTIONS = "AGENTS.md";
+
+// Grok and Codex read AGENTS.md directly; these must resolve to it.
+export const instructionSymlinks = Object.freeze(["CLAUDE.md", "GEMINI.md"]);
+
+function read(root, relative) {
+ return fs.readFileSync(path.join(root, relative), "utf8");
+}
+
+function listDirectories(root, relative) {
+ const dir = path.join(root, relative);
+
+ if (!fs.existsSync(dir)) {
+ return [];
+ }
+
+ return fs
+ .readdirSync(dir, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name)
+ .sort();
+}
+
+export function listCommands(root = repositoryRoot) {
+ const dir = path.join(root, COMMANDS_DIR);
+
+ if (!fs.existsSync(dir)) {
+ return [];
+ }
+
+ return fs
+ .readdirSync(dir)
+ .filter((name) => name.endsWith(".md"))
+ .map((name) => name.replace(/\.md$/u, ""))
+ .sort();
+}
+
+export function listSkills(root = repositoryRoot) {
+ return {
+ codex: listDirectories(root, CODEX_SKILLS_DIR),
+ claude: listDirectories(root, CLAUDE_SKILLS_DIR),
+ };
+}
+
+export function auditAgentSurfaces(root = repositoryRoot) {
+ const findings = [];
+ const commands = listCommands(root);
+ const { codex, claude } = listSkills(root);
+
+ for (const name of codex) {
+ if (!claude.includes(name)) {
+ findings.push(
+ `${CLAUDE_SKILLS_DIR}/${name}/SKILL.md is missing; Claude cannot use the ${name} skill`,
+ );
+ }
+ }
+
+ for (const name of claude) {
+ if (!codex.includes(name)) {
+ findings.push(
+ `${CODEX_SKILLS_DIR}/${name}/SKILL.md is missing; Codex cannot use the ${name} skill`,
+ );
+ }
+ }
+
+ for (const name of codex.filter((entry) => claude.includes(entry))) {
+ const adapter = read(root, `${CLAUDE_SKILLS_DIR}/${name}/SKILL.md`);
+
+ if (!adapter.includes(`${CODEX_SKILLS_DIR}/${name}/SKILL.md`)) {
+ findings.push(
+ `${CLAUDE_SKILLS_DIR}/${name}/SKILL.md must point at its canonical ${CODEX_SKILLS_DIR} body`,
+ );
+ }
+ }
+
+ // A command only reaches Codex through the router, so an unrouted command is
+ // invisible to every agent that does not read .claude/commands directly.
+ const routers = [
+ [CODEX_ROUTER, read(root, CODEX_ROUTER)],
+ [CLAUDE_ROUTER, read(root, CLAUDE_ROUTER)],
+ ];
+
+ for (const [file, text] of routers) {
+ for (const command of commands) {
+ if (!text.includes(`${COMMANDS_DIR}/${command}.md`)) {
+ findings.push(`${file} does not route ${command}`);
+ }
+ }
+ }
+
+ const instructions = read(root, INSTRUCTIONS);
+
+ for (const command of commands) {
+ if (!instructions.includes(`\`/${command}\``)) {
+ findings.push(`${INSTRUCTIONS} skills table is missing /${command}`);
+ }
+ }
+
+ for (const skill of codex) {
+ if (skill === "openiap-workflows") {
+ continue;
+ }
+
+ if (!instructions.includes(`\`$${skill}\``)) {
+ findings.push(`${INSTRUCTIONS} skills table is missing $${skill}`);
+ }
+ }
+
+ for (const link of instructionSymlinks) {
+ const target = path.join(root, link);
+
+ if (!fs.existsSync(target)) {
+ findings.push(`${link} is missing; it must symlink to ${INSTRUCTIONS}`);
+ continue;
+ }
+
+ if (
+ !fs.lstatSync(target).isSymbolicLink() ||
+ fs.readlinkSync(target) !== INSTRUCTIONS
+ ) {
+ findings.push(`${link} must be a symlink to ${INSTRUCTIONS}`);
+ }
+ }
+
+ return findings.sort();
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) {
+ const errors = auditAgentSurfaces();
+
+ if (errors.length === 0) {
+ console.log("Agent surface audit: clean.");
+ } else {
+ console.error("Agent surface audit failed:");
+ for (const error of errors) {
+ console.error(`- ${error}`);
+ }
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/audit-agent-surfaces.test.mjs b/scripts/audit-agent-surfaces.test.mjs
new file mode 100644
index 000000000..a6dea01e1
--- /dev/null
+++ b/scripts/audit-agent-surfaces.test.mjs
@@ -0,0 +1,142 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+
+import {
+ auditAgentSurfaces,
+ instructionSymlinks,
+ listCommands,
+ listSkills,
+} from "./audit-agent-surfaces.mjs";
+
+const repositoryRoot = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "..",
+);
+
+const MIRRORED = [
+ ".claude/commands",
+ ".claude/skills",
+ ".codex/skills",
+ "AGENTS.md",
+];
+
+function withMirroredRepository(mutate, run) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "openiap-agents-"));
+
+ try {
+ for (const entry of MIRRORED) {
+ const source = path.join(repositoryRoot, entry);
+ const target = path.join(root, entry);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.cpSync(source, target, { recursive: true });
+ }
+
+ for (const link of instructionSymlinks) {
+ fs.symlinkSync("AGENTS.md", path.join(root, link));
+ }
+
+ mutate(root);
+ run(root);
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+}
+
+test("the repository's agent surfaces agree", () => {
+ assert.deepEqual(auditAgentSurfaces(), []);
+});
+
+test("every command is discovered and every skill exists for both agents", () => {
+ const commands = listCommands();
+ const { codex, claude } = listSkills();
+
+ assert.ok(commands.includes("audit-iapkit"));
+ assert.deepEqual(codex, claude);
+});
+
+test("rejects a command no router mentions", () => {
+ withMirroredRepository(
+ (root) => {
+ fs.writeFileSync(
+ path.join(root, ".claude/commands/audit-orphan.md"),
+ "---\nname: audit-orphan\n---\n",
+ );
+ },
+ (root) => {
+ const findings = auditAgentSurfaces(root);
+ assert.ok(
+ findings.some((f) =>
+ f.includes(".codex/skills/openiap-workflows/SKILL.md does not route audit-orphan"),
+ ),
+ );
+ assert.ok(
+ findings.some((f) =>
+ f.includes(".claude/skills/openiap-workflows/SKILL.md does not route audit-orphan"),
+ ),
+ );
+ assert.ok(
+ findings.some((f) => f.includes("AGENTS.md skills table is missing /audit-orphan")),
+ );
+ },
+ );
+});
+
+test("rejects a skill that exists for only one agent", () => {
+ withMirroredRepository(
+ (root) => {
+ fs.rmSync(path.join(root, ".claude/skills/rebase-main"), {
+ recursive: true,
+ force: true,
+ });
+ },
+ (root) => {
+ assert.ok(
+ auditAgentSurfaces(root).some((f) =>
+ f.includes(".claude/skills/rebase-main/SKILL.md is missing"),
+ ),
+ );
+ },
+ );
+});
+
+test("rejects a Claude adapter that drops its canonical pointer", () => {
+ withMirroredRepository(
+ (root) => {
+ const file = path.join(root, ".claude/skills/rebase-main/SKILL.md");
+ fs.writeFileSync(
+ file,
+ fs
+ .readFileSync(file, "utf8")
+ .replaceAll(".codex/skills/rebase-main/SKILL.md", "somewhere else"),
+ );
+ },
+ (root) => {
+ assert.ok(
+ auditAgentSurfaces(root).some((f) =>
+ f.includes("must point at its canonical .codex/skills body"),
+ ),
+ );
+ },
+ );
+});
+
+test("rejects instruction files that stop resolving to AGENTS.md", () => {
+ withMirroredRepository(
+ (root) => {
+ const link = path.join(root, "CLAUDE.md");
+ fs.unlinkSync(link);
+ fs.writeFileSync(link, "# not a symlink\n");
+ },
+ (root) => {
+ assert.ok(
+ auditAgentSurfaces(root).some(
+ (f) => f === "CLAUDE.md must be a symlink to AGENTS.md",
+ ),
+ );
+ },
+ );
+});
diff --git a/scripts/audit-ci-path-filters.mjs b/scripts/audit-ci-path-filters.mjs
new file mode 100644
index 000000000..dd68c8d8d
--- /dev/null
+++ b/scripts/audit-ci-path-filters.mjs
@@ -0,0 +1,621 @@
+#!/usr/bin/env node
+
+// Guards the path filters that gate native builds and Swift CodeQL.
+// Filters are read from the workflows themselves, so there is no second copy.
+
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { parse as parseYaml } from "yaml";
+
+const scriptPath = fileURLToPath(import.meta.url);
+const repositoryRoot = path.resolve(path.dirname(scriptPath), "..");
+
+const DOCS_EXCLUDE = "!**/*.md";
+
+export const nativeWorkflows = Object.freeze([
+ "ci-expo-iap.yml",
+ "ci-flutter-inapp-purchase.yml",
+ "ci-godot-iap.yml",
+ "ci-kmp-iap.yml",
+ "ci-maui-iap.yml",
+ "ci-react-native-iap.yml",
+]);
+
+export const ciFilterJobs = Object.freeze({
+ android: "ci:test-android",
+ docs: "ci:test-docs",
+ gql: "ci:test-gql",
+ ios: "ci:test-ios",
+ web: "ci:web-e2e",
+});
+
+export const unconditionalCiJobs = Object.freeze([
+ "audit-lockfile",
+ "audit-parity",
+ "audit-release-state",
+ "changes",
+ "test-agent",
+ "test-conformance",
+]);
+
+function readWorkflow(root, name) {
+ return parseYaml(
+ fs.readFileSync(path.join(root, ".github/workflows", name), "utf8"),
+ );
+}
+
+function dornyStep(document, jobId, stepId) {
+ const step = document.jobs[jobId].steps.find((entry) => entry.id === stepId);
+ return { step, filters: parseYaml(step.with.filters) };
+}
+
+// The audited vocabulary is exactly three forms, so ordered (GitHub native)
+// and polarity-based (dorny some-with-excludes) evaluation coincide.
+export function matchesPattern(pattern, file) {
+ if (pattern === "**/*.md") {
+ return file.endsWith(".md");
+ }
+
+ if (pattern.endsWith("/**")) {
+ return file.startsWith(pattern.slice(0, -2));
+ }
+
+ return file === pattern;
+}
+
+export function matchesFilter(patterns, files) {
+ const includes = patterns.filter((entry) => !entry.startsWith("!"));
+ const excludes = patterns
+ .filter((entry) => entry.startsWith("!"))
+ .map((entry) => entry.slice(1));
+
+ return files.some(
+ (file) =>
+ includes.some((pattern) => matchesPattern(pattern, file)) &&
+ !excludes.some((pattern) => matchesPattern(pattern, file)),
+ );
+}
+
+export function readScopes(root = repositoryRoot) {
+ const codeql = readWorkflow(root, "codeql.yml");
+ const ci = readWorkflow(root, "ci.yml");
+ const core = dornyStep(codeql, "codeql-scope", "core");
+ const wrappers = dornyStep(codeql, "codeql-scope", "wrappers");
+ const changes = dornyStep(ci, "changes", "filter");
+ const scopes = new Map();
+
+ scopes.set("codeql:analyze-swift", core.filters.swift_core);
+
+ for (const [component, patterns] of Object.entries(wrappers.filters)) {
+ scopes.set(`codeql:analyze-swift-wrappers/${component}`, patterns);
+ }
+
+ for (const [name, patterns] of Object.entries(changes.filters)) {
+ const jobId = ciFilterJobs[name];
+
+ if (!jobId) {
+ throw new Error(
+ `ci.yml: filter '${name}' has no entry in ciFilterJobs; map it to the job it gates`,
+ );
+ }
+
+ scopes.set(jobId, patterns);
+ }
+
+ for (const name of nativeWorkflows) {
+ scopes.set(name, readWorkflow(root, name).on.pull_request.paths);
+ }
+
+ return scopes;
+}
+
+export function selectJobs(files, root = repositoryRoot) {
+ return [...readScopes(root)]
+ .filter(([, patterns]) => matchesFilter(patterns, files))
+ .map(([jobId]) => jobId)
+ .sort();
+}
+
+const ALL_SWIFT_WRAPPERS = [
+ "codeql:analyze-swift-wrappers/expo",
+ "codeql:analyze-swift-wrappers/expo-onside",
+ "codeql:analyze-swift-wrappers/flutter",
+ "codeql:analyze-swift-wrappers/godot",
+ "codeql:analyze-swift-wrappers/react-native",
+];
+
+export const cases = Object.freeze([
+ {
+ name: "apple-core-docs-only",
+ files: [
+ "packages/apple/README.md",
+ "packages/apple/CONVENTION.md",
+ "packages/apple/CONTRIBUTING.md",
+ ],
+ jobs: [],
+ },
+ {
+ name: "apple-core-source",
+ files: ["packages/apple/Sources/OpenIapModule.swift"],
+ jobs: [
+ "ci-expo-iap.yml",
+ "ci-flutter-inapp-purchase.yml",
+ "ci-maui-iap.yml",
+ "ci-react-native-iap.yml",
+ "ci:test-ios",
+ "codeql:analyze-swift",
+ ],
+ },
+ {
+ name: "apple-core-generated-types",
+ files: ["packages/apple/Sources/Models/Types.swift"],
+ jobs: [
+ "ci-expo-iap.yml",
+ "ci-flutter-inapp-purchase.yml",
+ "ci-maui-iap.yml",
+ "ci-react-native-iap.yml",
+ "ci:test-ios",
+ "codeql:analyze-swift",
+ ],
+ },
+ {
+ name: "apple-core-manifest",
+ files: ["packages/apple/Package.swift"],
+ jobs: [
+ "ci-expo-iap.yml",
+ "ci-flutter-inapp-purchase.yml",
+ "ci-react-native-iap.yml",
+ "ci:test-ios",
+ "codeql:analyze-swift",
+ ],
+ },
+ {
+ name: "apple-core-build-script",
+ files: ["packages/apple/scripts/build-xcframework.sh"],
+ jobs: ["ci-maui-iap.yml", "ci:test-ios", "codeql:analyze-swift"],
+ },
+ {
+ name: "kmp-swift-bridge-source",
+ files: [
+ "libraries/kmp-iap/native/InAppPurchaseBridge/Sources/InAppPurchaseBridge/InAppPurchaseBridge.swift",
+ ],
+ jobs: ["ci-kmp-iap.yml", "codeql:analyze-swift"],
+ },
+ {
+ name: "kmp-docs-only",
+ files: [
+ "libraries/kmp-iap/AGENTS.md",
+ "libraries/kmp-iap/CLAUDE.md",
+ "libraries/kmp-iap/.vscode/README_IOS_DEVICE.md",
+ ],
+ jobs: [],
+ },
+ {
+ name: "kmp-vscode-launch-script",
+ files: ["libraries/kmp-iap/.vscode/run_ios.sh"],
+ jobs: ["ci-kmp-iap.yml"],
+ },
+ {
+ name: "react-native-docs-only",
+ files: [
+ "libraries/react-native-iap/AGENTS.md",
+ "libraries/react-native-iap/CLAUDE.md",
+ "libraries/react-native-iap/.claude/commands/commit.md",
+ "libraries/react-native-iap/LICENSE.md",
+ "libraries/react-native-iap/example/README.md",
+ ],
+ jobs: [],
+ },
+ {
+ name: "react-native-swift-source",
+ files: ["libraries/react-native-iap/ios/HybridRnIap.swift"],
+ jobs: [
+ "ci-react-native-iap.yml",
+ "codeql:analyze-swift-wrappers/react-native",
+ ],
+ },
+ {
+ name: "react-native-nitro-spec",
+ files: [
+ "libraries/react-native-iap/src/specs/RnIap.nitro.ts",
+ "libraries/react-native-iap/nitro.json",
+ ],
+ jobs: [
+ "ci-react-native-iap.yml",
+ "codeql:analyze-swift-wrappers/react-native",
+ ],
+ },
+ {
+ name: "expo-docs-only",
+ files: [
+ "libraries/expo-iap/README.md",
+ "libraries/expo-iap/CHANGELOG.md",
+ "libraries/expo-iap/GEMINI.md",
+ "libraries/expo-iap/example/README.md",
+ ],
+ jobs: [],
+ },
+ {
+ name: "expo-swift-source",
+ files: ["libraries/expo-iap/ios/ExpoIapModule.swift"],
+ jobs: [
+ "ci-expo-iap.yml",
+ "codeql:analyze-swift-wrappers/expo",
+ "codeql:analyze-swift-wrappers/expo-onside",
+ ],
+ },
+ {
+ name: "expo-onside-swift-source",
+ files: ["libraries/expo-iap/ios/onside/OnsideIapModule.swift"],
+ jobs: [
+ "ci-expo-iap.yml",
+ "codeql:analyze-swift-wrappers/expo",
+ "codeql:analyze-swift-wrappers/expo-onside",
+ ],
+ },
+ {
+ name: "expo-onside-podfile-plugin",
+ files: ["libraries/expo-iap/plugin/src/onsidePodfile.ts"],
+ jobs: [
+ "ci-expo-iap.yml",
+ "codeql:analyze-swift-wrappers/expo",
+ "codeql:analyze-swift-wrappers/expo-onside",
+ ],
+ },
+ {
+ name: "flutter-docs-only",
+ files: [
+ "libraries/flutter_inapp_purchase/CHANGELOG.md",
+ "libraries/flutter_inapp_purchase/CONVENTION.md",
+ "libraries/flutter_inapp_purchase/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md",
+ ],
+ jobs: [],
+ },
+ {
+ name: "flutter-build-script",
+ files: [
+ "libraries/flutter_inapp_purchase/scripts/verify-apple-swiftpm-consumer-build.sh",
+ ],
+ jobs: [
+ "ci-flutter-inapp-purchase.yml",
+ "codeql:analyze-swift-wrappers/flutter",
+ ],
+ },
+ {
+ name: "godot-docs-only",
+ files: [
+ "libraries/godot-iap/.claude/guides/03-ios-plugin.md",
+ "libraries/godot-iap/EXAMPLES.md",
+ ],
+ jobs: [],
+ },
+ {
+ name: "godot-swift-source",
+ files: ["libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift"],
+ jobs: ["ci-godot-iap.yml", "codeql:analyze-swift-wrappers/godot"],
+ },
+ {
+ name: "godot-addon-behind-symlink",
+ files: ["libraries/godot-iap/addons/godot-iap/godot_iap.gd"],
+ jobs: ["ci-godot-iap.yml", "codeql:analyze-swift-wrappers/godot"],
+ },
+ {
+ name: "google-docs-only",
+ files: [
+ "packages/google/README.md",
+ "packages/google/ALTERNATIVE_BILLING.md",
+ ],
+ jobs: [],
+ },
+ {
+ name: "google-source",
+ files: ["packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt"],
+ jobs: ["ci-kmp-iap.yml", "ci-maui-iap.yml", "ci:test-android"],
+ },
+ {
+ name: "root-version-manifest",
+ files: ["openiap-versions.json"],
+ jobs: [
+ ...nativeWorkflows,
+ "ci:test-android",
+ "ci:test-gql",
+ "ci:test-ios",
+ "codeql:analyze-swift",
+ ...ALL_SWIFT_WRAPPERS,
+ ],
+ },
+ {
+ name: "libraries-versions-manifest",
+ files: ["libraries-versions.jsonc"],
+ jobs: [
+ "ci-expo-iap.yml",
+ "ci-flutter-inapp-purchase.yml",
+ "ci-react-native-iap.yml",
+ "codeql:analyze-swift-wrappers/expo",
+ "codeql:analyze-swift-wrappers/expo-onside",
+ "codeql:analyze-swift-wrappers/flutter",
+ "codeql:analyze-swift-wrappers/react-native",
+ ],
+ },
+ {
+ name: "codeql-workflow-edit",
+ files: [".github/workflows/codeql.yml"],
+ jobs: ["codeql:analyze-swift", ...ALL_SWIFT_WRAPPERS],
+ },
+ {
+ name: "maui-binding-source",
+ files: ["libraries/maui-iap/src/OpenIap.Maui/Types.cs"],
+ jobs: ["ci-maui-iap.yml", "ci:test-gql"],
+ },
+ {
+ // kit ships .md as bundled site content, so `web` keeps every markdown path.
+ name: "kit-content-markdown",
+ files: ["packages/kit/src/content/privacy-policy.md"],
+ jobs: ["ci:web-e2e"],
+ },
+ {
+ name: "docs-site-source",
+ files: ["packages/docs/src/pages/docs/index.tsx"],
+ jobs: ["ci:test-docs", "ci:web-e2e"],
+ },
+ {
+ // docs and web intentionally keep markdown; both are cheap ubuntu jobs.
+ name: "docs-markdown",
+ files: ["packages/docs/README.md"],
+ jobs: ["ci:test-docs", "ci:web-e2e"],
+ },
+ {
+ name: "scripts-docs-only",
+ files: ["scripts/agent/README.md"],
+ jobs: [],
+ },
+ {
+ name: "mixed-docs-and-source",
+ files: [
+ "libraries/expo-iap/README.md",
+ "libraries/godot-iap/ios-gdextension/Sources/GodotIap/Binder.swift",
+ ],
+ jobs: ["ci-godot-iap.yml", "codeql:analyze-swift-wrappers/godot"],
+ },
+ {
+ name: "mixed-two-wrappers",
+ files: [
+ "libraries/expo-iap/ios/ExpoIapLog.swift",
+ "libraries/godot-iap/ios-gdextension/Package.swift",
+ ],
+ jobs: [
+ "ci-expo-iap.yml",
+ "ci-godot-iap.yml",
+ "codeql:analyze-swift-wrappers/expo",
+ "codeql:analyze-swift-wrappers/expo-onside",
+ "codeql:analyze-swift-wrappers/godot",
+ ],
+ },
+ {
+ name: "pr-361-docs-replay",
+ files: [
+ "AGENTS.md",
+ "libraries/expo-iap/AGENTS.md",
+ "libraries/godot-iap/CLAUDE.md",
+ "libraries/maui-iap/CONVENTION.md",
+ "libraries/react-native-iap/GEMINI.md",
+ "knowledge/internal/02-architecture.md",
+ "packages/apple/CONTRIBUTING.md",
+ ],
+ jobs: [],
+ },
+]);
+
+function findVocabularyViolations(scopes) {
+ const findings = [];
+
+ for (const [scope, patterns] of scopes) {
+ const hasExclude = patterns.some((pattern) => pattern.startsWith("!"));
+
+ for (const pattern of patterns) {
+ if (pattern.startsWith("!") && pattern !== DOCS_EXCLUDE) {
+ findings.push(
+ `${scope}: unsupported negation '${pattern}'; only '${DOCS_EXCLUDE}' is proven equivalent across both matchers`,
+ );
+ continue;
+ }
+
+ if (
+ hasExclude &&
+ !pattern.startsWith("!") &&
+ (pattern.endsWith(".md") || pattern.endsWith(".mdx"))
+ ) {
+ findings.push(
+ `${scope}: positive '${pattern}' targets markdown and would be unreachable behind '${DOCS_EXCLUDE}'`,
+ );
+ }
+
+ const body = pattern.startsWith("!") ? pattern.slice(1) : pattern;
+ const literal = body.endsWith("/**") ? body.slice(0, -3) : body;
+
+ if (body !== "**/*.md" && /[*?+[\]{}]/u.test(literal)) {
+ findings.push(
+ `${scope}: pattern '${pattern}' is outside the audited vocabulary`,
+ );
+ }
+ }
+ }
+
+ return findings;
+}
+
+function findQuantifierViolations(root) {
+ const codeql = readWorkflow(root, "codeql.yml");
+ const ci = readWorkflow(root, "ci.yml");
+ const steps = [
+ ["codeql.yml", "codeql-scope", "core", dornyStep(codeql, "codeql-scope", "core")],
+ [
+ "codeql.yml",
+ "codeql-scope",
+ "wrappers",
+ dornyStep(codeql, "codeql-scope", "wrappers"),
+ ],
+ ["ci.yml", "changes", "filter", dornyStep(ci, "changes", "filter")],
+ ];
+
+ return steps.flatMap(([file, jobId, stepId, { step, filters }]) => {
+ const negates = Object.values(filters).some((patterns) =>
+ patterns.some((pattern) => pattern.startsWith("!")),
+ );
+
+ if (!negates || step.with["predicate-quantifier"] === "some-with-excludes") {
+ return [];
+ }
+
+ return [
+ `${file}:${jobId}/${stepId}: negated patterns require predicate-quantifier: some-with-excludes`,
+ ];
+ });
+}
+
+function findCoverageViolations(root) {
+ const findings = [];
+ const codeql = readWorkflow(root, "codeql.yml");
+ const ci = readWorkflow(root, "ci.yml");
+
+ for (const [name, document] of [
+ ["codeql.yml", codeql],
+ ["ci.yml", ci],
+ ]) {
+ for (const event of ["pull_request", "push"]) {
+ const trigger = document.on[event] ?? {};
+
+ if (trigger.paths || trigger["paths-ignore"]) {
+ findings.push(
+ `${name}: ${event} must stay unfiltered; job-level gating keeps skipped checks reportable`,
+ );
+ }
+ }
+ }
+
+ for (const event of ["schedule", "workflow_dispatch"]) {
+ if (!(event in codeql.on)) {
+ findings.push(`codeql.yml: ${event} coverage was removed`);
+ }
+ }
+
+ for (const output of ["swift_core", "swift_wrappers", "swift_components"]) {
+ const expression = codeql.jobs["codeql-scope"].outputs[output] ?? "";
+
+ if (!expression.includes("github.event_name != 'pull_request'")) {
+ findings.push(`codeql.yml: ${output} lost its non-pull_request fallback`);
+ }
+ }
+
+ for (const stepId of ["core", "wrappers"]) {
+ const { step } = dornyStep(codeql, "codeql-scope", stepId);
+
+ if (step.if !== "github.event_name == 'pull_request'") {
+ findings.push(
+ `codeql.yml: ${stepId} step must stay pull_request-only so other events fall back to full scope`,
+ );
+ }
+ }
+
+ for (const jobId of unconditionalCiJobs) {
+ const job = ci.jobs[jobId];
+
+ if (!job) {
+ findings.push(`ci.yml: job ${jobId} is missing`);
+ continue;
+ }
+
+ if ("needs" in job || "if" in job) {
+ findings.push(`ci.yml: job ${jobId} must stay unconditional`);
+ }
+ }
+
+ // A filter only means something through the job it gates, so pin that wiring.
+ const { filters: ciFilters } = dornyStep(ci, "changes", "filter");
+
+ for (const [name, label] of Object.entries(ciFilterJobs)) {
+ const jobId = label.replace(/^ci:/u, "");
+ const job = ci.jobs[jobId];
+
+ if (!(name in ciFilters)) {
+ findings.push(
+ `ci.yml: filter '${name}' was removed but job ${jobId} still gates on it`,
+ );
+ continue;
+ }
+
+ if (!job) {
+ findings.push(`ci.yml: job ${jobId} is missing; update ciFilterJobs`);
+ continue;
+ }
+
+ if (job.if !== `needs.changes.outputs.${name} == 'true'`) {
+ findings.push(
+ `ci.yml: job ${jobId} must gate on needs.changes.outputs.${name}`,
+ );
+ }
+
+ if (
+ ci.jobs.changes.outputs[name] !== `\${{ steps.filter.outputs.${name} }}`
+ ) {
+ findings.push(`ci.yml: changes job must publish the ${name} filter output`);
+ }
+ }
+
+ for (const name of nativeWorkflows) {
+ const document = readWorkflow(root, name);
+ const pullRequest = document.on.pull_request.paths;
+ const push = document.on.push.paths;
+
+ if (JSON.stringify(pullRequest) !== JSON.stringify(push)) {
+ findings.push(`${name}: push.paths must equal pull_request.paths`);
+ }
+
+ if (pullRequest.at(-1) !== DOCS_EXCLUDE) {
+ findings.push(`${name}: '${DOCS_EXCLUDE}' must be the last paths entry`);
+ }
+
+ if (!pullRequest.some((pattern) => !pattern.startsWith("!"))) {
+ findings.push(`${name}: paths needs at least one non-negated pattern`);
+ }
+ }
+
+ return findings;
+}
+
+export function findPolicyViolations(root = repositoryRoot) {
+ return [
+ ...findQuantifierViolations(root),
+ ...findVocabularyViolations(readScopes(root)),
+ ...findCoverageViolations(root),
+ ];
+}
+
+export function auditCiPathFilters(root = repositoryRoot) {
+ const selectionFindings = cases.flatMap(({ name, files, jobs }) => {
+ const expected = [...jobs].sort();
+ const actual = selectJobs(files, root);
+
+ return JSON.stringify(actual) === JSON.stringify(expected)
+ ? []
+ : [`${name}: expected [${expected}], got [${actual}]`];
+ });
+
+ return [...selectionFindings, ...findPolicyViolations(root)].sort();
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) {
+ const errors = auditCiPathFilters();
+
+ if (errors.length === 0) {
+ console.log("CI path filter audit: clean.");
+ } else {
+ console.error("CI path filter audit failed:");
+ for (const error of errors) {
+ console.error(`- ${error}`);
+ }
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/audit-ci-path-filters.test.mjs b/scripts/audit-ci-path-filters.test.mjs
new file mode 100644
index 000000000..a82a92e86
--- /dev/null
+++ b/scripts/audit-ci-path-filters.test.mjs
@@ -0,0 +1,210 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+
+import {
+ auditCiPathFilters,
+ cases,
+ findPolicyViolations,
+ matchesFilter,
+ selectJobs,
+} from "./audit-ci-path-filters.mjs";
+
+const repositoryRoot = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "..",
+);
+
+function withPatchedWorkflows(patch, run) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "openiap-ci-paths-"));
+
+ try {
+ fs.cpSync(
+ path.join(repositoryRoot, ".github/workflows"),
+ path.join(root, ".github/workflows"),
+ { recursive: true },
+ );
+ patch(path.join(root, ".github/workflows"));
+ run(root);
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+}
+
+for (const { name, files, jobs } of cases) {
+ test(`selects the expected jobs for ${name}`, () => {
+ assert.deepEqual(selectJobs(files), [...jobs].sort());
+ });
+}
+
+test("workflow path filters satisfy the audited policy", () => {
+ assert.deepEqual(auditCiPathFilters(), []);
+});
+
+test("markdown exclusion is final and cannot be re-included", () => {
+ const patterns = ["packages/apple/**", "!**/*.md"];
+
+ assert.equal(matchesFilter(patterns, ["packages/apple/README.md"]), false);
+ assert.equal(matchesFilter(patterns, ["packages/apple/Sources/A.swift"]), true);
+ // Order must not change the answer; that is what makes one list safe in both
+ // dorny (polarity) and GitHub native paths (last match wins).
+ assert.equal(
+ matchesFilter(["!**/*.md", "packages/apple/**"], ["packages/apple/README.md"]),
+ false,
+ );
+});
+
+test("rejects negation without the some-with-excludes quantifier", () => {
+ withPatchedWorkflows(
+ (workflows) => {
+ const file = path.join(workflows, "codeql.yml");
+ fs.writeFileSync(
+ file,
+ fs
+ .readFileSync(file, "utf8")
+ .replaceAll(" predicate-quantifier: some-with-excludes\n", ""),
+ );
+ },
+ (root) => {
+ assert.deepEqual(
+ findPolicyViolations(root).filter((finding) =>
+ finding.includes("predicate-quantifier"),
+ ),
+ [
+ "codeql.yml:codeql-scope/core: negated patterns require predicate-quantifier: some-with-excludes",
+ "codeql.yml:codeql-scope/wrappers: negated patterns require predicate-quantifier: some-with-excludes",
+ ],
+ );
+ },
+ );
+});
+
+test("rejects negation forms outside the audited vocabulary", () => {
+ withPatchedWorkflows(
+ (workflows) => {
+ const file = path.join(workflows, "codeql.yml");
+ fs.writeFileSync(
+ file,
+ fs
+ .readFileSync(file, "utf8")
+ .replace(
+ " - 'libraries/expo-iap/**'\n - 'libraries-versions.jsonc'",
+ " - 'libraries/expo-iap/**'\n - '!libraries/expo-iap/docs/**'\n - 'libraries-versions.jsonc'",
+ ),
+ );
+ },
+ (root) => {
+ assert.ok(
+ findPolicyViolations(root).some((finding) =>
+ finding.includes("unsupported negation '!libraries/expo-iap/docs/**'"),
+ ),
+ );
+ },
+ );
+});
+
+test("rejects a workflow-level paths filter on ci.yml or codeql.yml", () => {
+ withPatchedWorkflows(
+ (workflows) => {
+ const file = path.join(workflows, "ci.yml");
+ fs.writeFileSync(
+ file,
+ fs
+ .readFileSync(file, "utf8")
+ .replace(
+ "on:\n pull_request:\n branches:\n - main\n - next\n",
+ "on:\n pull_request:\n branches:\n - main\n - next\n paths:\n - 'packages/**'\n",
+ ),
+ );
+ },
+ (root) => {
+ assert.ok(
+ findPolicyViolations(root).some(
+ (finding) => finding === "ci.yml: pull_request must stay unfiltered; job-level gating keeps skipped checks reportable",
+ ),
+ );
+ },
+ );
+});
+
+test("rejects rewiring a gated job onto the wrong filter", () => {
+ withPatchedWorkflows(
+ (workflows) => {
+ const file = path.join(workflows, "ci.yml");
+ fs.writeFileSync(
+ file,
+ fs
+ .readFileSync(file, "utf8")
+ .replace(
+ " if: needs.changes.outputs.ios == 'true'",
+ " if: needs.changes.outputs.docs == 'true'",
+ ),
+ );
+ },
+ (root) => {
+ assert.ok(
+ findPolicyViolations(root).includes(
+ "ci.yml: job test-ios must gate on needs.changes.outputs.ios",
+ ),
+ );
+ },
+ );
+});
+
+test("rejects deleting a filter that a job still gates on", () => {
+ withPatchedWorkflows(
+ (workflows) => {
+ const file = path.join(workflows, "ci.yml");
+ fs.writeFileSync(
+ file,
+ fs
+ .readFileSync(file, "utf8")
+ .replace(
+ ` docs:
+ - 'packages/docs/**'
+ - 'packages/gql/src/generated/**'
+ - 'packages/gql/generated-sync-manifest.mjs'
+ - 'scripts/audit-docs.ts'
+ - 'scripts/audit-docs.test.ts'
+ - '.github/workflows/ci.yml'
+`,
+ "",
+ ),
+ );
+ },
+ (root) => {
+ assert.ok(
+ findPolicyViolations(root).includes(
+ "ci.yml: filter 'docs' was removed but job test-docs still gates on it",
+ ),
+ );
+ },
+ );
+});
+
+test("rejects gating a job that guards the markdown corpus", () => {
+ withPatchedWorkflows(
+ (workflows) => {
+ const file = path.join(workflows, "ci.yml");
+ fs.writeFileSync(
+ file,
+ fs
+ .readFileSync(file, "utf8")
+ .replace(
+ " audit-parity:\n name: Audit SDK Parity\n",
+ " audit-parity:\n name: Audit SDK Parity\n needs: changes\n",
+ ),
+ );
+ },
+ (root) => {
+ assert.ok(
+ findPolicyViolations(root).includes(
+ "ci.yml: job audit-parity must stay unconditional",
+ ),
+ );
+ },
+ );
+});
diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs
index ce3573baf..0d3c9e546 100644
--- a/scripts/audit-non-godot-parity.mjs
+++ b/scripts/audit-non-godot-parity.mjs
@@ -6026,16 +6026,25 @@ function checkFrameworkDependencyHygiene() {
`${releaseNotesWorkflow} release notes must use the release tag when it already exists`,
);
}
- for (const xcodeReleaseWorkflow of [
+ expectIncludes(
".github/workflows/ci.yml",
+ [
+ "runs-on: macos-26",
+ "XCODE_VERSION: 26.6",
+ "maxim-lobanov/setup-xcode@",
+ "xcode-version: ${{ env.XCODE_VERSION }}",
+ ],
+ ".github/workflows/ci.yml must pin the macOS/Xcode release image",
+ );
+ for (const xcodeReleaseWorkflow of [
".github/workflows/release-apple.yml",
".github/workflows/release-kmp.yml",
]) {
expectIncludes(
xcodeReleaseWorkflow,
[
- "runs-on: macos-15",
- "XCODE_VERSION: 16.4",
+ "runs-on: macos-26",
+ "XCODE_VERSION: 26.6",
"maxim-lobanov/setup-xcode@",
"xcode-version: ${{ env.XCODE_VERSION }}",
],
@@ -7447,6 +7456,7 @@ function checkFrameworkDependencyHygiene() {
"libraries/expo-iap/plugin/src/withVega.ts",
"libraries/expo-iap/example/vega/package.json",
"libraries/react-native-iap/example/vega/package.json",
+ "packages/docs/src/pages/docs/setup/store/amazon.tsx",
]) {
expectIncludes(
vegaDependencyFile,
@@ -7464,6 +7474,36 @@ function checkFrameworkDependencyHygiene() {
["^0.0.7"],
"Expo Vega plugin must install the current compatibility Metro config",
);
+ for (const vegaManifestFile of [
+ "libraries/expo-iap/plugin/src/withVega.ts",
+ "libraries/expo-iap/example/scripts/vega-build-config.mjs",
+ "libraries/react-native-iap/example/manifest.toml",
+ "packages/docs/src/pages/docs/setup/store/amazon.tsx",
+ ]) {
+ expectIncludes(
+ vegaManifestFile,
+ ["/com.amazon.vega.os@IVega_1_2", "[os.version]"],
+ "Vega manifests must declare the OS module and version required since Vega SDK 0.24",
+ );
+ }
+ // withVega.ts interpolates the version, so pin the constant there and the
+ // literal target/min pair in the emitted manifests.
+ expectIncludes(
+ "libraries/expo-iap/plugin/src/withVega.ts",
+ ["const VEGA_OS_VERSION = '1.2'"],
+ "Expo Vega plugin must pin the Vega OS version constant",
+ );
+ for (const literalVegaManifest of [
+ "libraries/expo-iap/example/scripts/vega-build-config.mjs",
+ "libraries/react-native-iap/example/manifest.toml",
+ "packages/docs/src/pages/docs/setup/store/amazon.tsx",
+ ]) {
+ expectIncludes(
+ literalVegaManifest,
+ ['target = "1.2"', 'min = "1.2"'],
+ "Vega manifests must pin the OS target and minimum version",
+ );
+ }
expectOptionalIncludes(
"libraries/expo-iap/example/android/settings.gradle",
[
@@ -8944,8 +8984,8 @@ function checkXcode27StoreKitCoverage() {
"openiap-versions.json",
'".github/workflows/release-flutter.yml"',
"apple-cocoapods:",
- "runs-on: macos-15",
- "XCODE_VERSION: 16.4",
+ "runs-on: macos-26",
+ "XCODE_VERSION: 26.6",
"maxim-lobanov/setup-xcode@",
"xcode-version: ${{ env.XCODE_VERSION }}",
],
diff --git a/scripts/audit-security.test.mjs b/scripts/audit-security.test.mjs
index 0ec5bb9df..e45c7129a 100644
--- a/scripts/audit-security.test.mjs
+++ b/scripts/audit-security.test.mjs
@@ -593,11 +593,28 @@ test("CodeQL scopes Swift pull requests to public macOS runners", () => {
/cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}/u,
);
assert.match(scope, /swift_core:/u);
- assert.match(swiftCore, /needs: codeql-scope/u);
+ assert.match(swiftCore, /needs: \[codeql-scope, pick-mac-runner\]/u);
assert.match(
swiftCore,
/if: needs\.codeql-scope\.outputs\.swift_core == 'true'/u,
);
+ assert.match(
+ swiftCore,
+ /runs-on: \$\{\{ needs\.pick-mac-runner\.outputs\.runner \}\}/u,
+ );
+ // The gate may hand a job to the self-hosted Mac only for the owner's own
+ // pull requests, and it always falls back to the hosted image.
+ const gate = workflow.slice(
+ workflow.indexOf(" pick-mac-runner:"),
+ workflow.indexOf(" analyze-swift:"),
+ );
+ assert.match(
+ gate,
+ /github\.event\.pull_request\.user\.login == 'hyochan' && github\.actor == 'hyochan'/u,
+ );
+ assert.match(gate, /github\.event_name == 'pull_request'/u);
+ assert.match(gate, /runner='macos-26'/u);
+ assert.match(gate, /-lt 900/u);
assert.match(scope, /react-native:/u);
assert.match(scope, /expo-onside:/u);
assert.match(scope, /flutter:/u);
@@ -614,9 +631,15 @@ test("CodeQL scopes Swift pull requests to public macOS runners", () => {
wrappers,
/github\.event\.pull_request\.head\.repo\.full_name == github\.repository/u,
);
+ // Pushes keep the xcode-27 split; PR legs stay hosted except godot, which
+ // may ride the owner-gated Mac.
+ assert.match(
+ wrappers,
+ /github\.event_name != 'pull_request'\s+&& \(matrix\.component == 'godot' && 'macos-26' \|\| 'xcode-27'\)/u,
+ );
assert.match(
wrappers,
- /runs-on: \$\{\{ \(github\.event_name == 'pull_request' \|\| matrix\.component == 'godot'\) && 'macos-26' \|\| 'xcode-27' \}\}/u,
+ /\|\| needs\.pick-mac-runner\.outputs\.runner \}\}/u,
);
assert.match(
wrappers,
diff --git a/scripts/ci/mac-runner-heartbeat.sh b/scripts/ci/mac-runner-heartbeat.sh
new file mode 100755
index 000000000..2e267a80b
--- /dev/null
+++ b/scripts/ci/mac-runner-heartbeat.sh
@@ -0,0 +1,18 @@
+#!/bin/sh
+# Refreshes the MAC_CI heartbeat (unix epoch) while the Actions runner on this
+# machine is alive. Workflow gate jobs treat a heartbeat older than 15 minutes
+# as "Mac is off" and fall back to GitHub-hosted runners, so nothing hangs when
+# the machine sleeps or shuts down.
+#
+# Install on the Mac Mini (once) as a LaunchAgent (cron edits can hang on
+# macOS TCC): ~/Library/LaunchAgents/dev.openiap.mac-ci-heartbeat.plist running
+# this script with StartInterval 300 + RunAtLoad, then
+# launchctl bootstrap gui/$(id -u)
+# Requires `gh auth` with repo admin.
+set -eu
+
+# cron ships a minimal PATH without Homebrew.
+PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
+
+pgrep -q "Runner.Listener" || exit 0
+exec gh variable set MAC_CI --repo hyodotdev/openiap --body "$(date +%s)"