diff --git a/packages/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt index e7861a381..775ae5698 100644 --- a/packages/docs/public/llms-full.txt +++ b/packages/docs/public/llms-full.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Quick Reference: https://openiap.dev/llms.txt -> Generated: 2026-08-01T17:31:39.495Z +> Generated: 2026-08-01T22:00:58.764Z ## Table of Contents 1. Installation @@ -2024,6 +2024,12 @@ private state machine and retention policy. IAPKit lives in the OpenIAP monorepo as a Bun + Hono server, Convex backend, and React SPA deployed behind one origin. +The official hosted service is free under fair-use safeguards on shared, +community-funded capacity. It is best-effort, not unlimited or SLA-backed. +High-volume apps should contact hyo@hyo.dev before launch, help fund shared +capacity through GitHub Sponsors or OpenCollective, or self-host the +MIT-licensed server for dedicated capacity. + ## API quick reference Base URL: https://kit.openiap.dev @@ -2056,7 +2062,7 @@ IAPKit does not relay those events through SSE, WebSockets, push, or long polling. Apps persist only the user-scoped fields they need and conditionally refresh on cold start, stale foreground, or explicit user action. Each refresh still performs one mutation-free indexed Convex query so an expiry with no new -webhook is detected. Respect `429 Retry-After`, coalesce concurrent refreshes, +webhook is detected. Respect the `Retry-After` header on `429` and `503`, coalesce concurrent refreshes, and enforce an app-defined maximum stale age for offline fallback. Secret-key responses are `private, no-store` and omit `ETag`. The current `kitApi.status()` and `kitApi.entitlements()` helpers are unconditional, @@ -2130,15 +2136,29 @@ Harmonized `state` values (truthy `isValid`): `ENTITLED`, - `403 INSUFFICIENT_SCOPE` — publishable key used for an administrative operation - `403 INVALID_API_KEY` — wrong scheme or malformed key (format check only) - `410 SECRET_API_KEY_IN_URL` — move the secret to `Authorization: Bearer ...` on the canonical route -- `429 RATE_LIMITED` — per-key bucket empty; honor `Retry-After` seconds +- `429 RATE_LIMITED` — API-key, source-IP, or process bucket empty; inspect `X-RateLimit-Scope` and honor `Retry-After` +- `503 SERVICE_BUSY` — the API-key, source-IP, or process verification share is full; inspect `X-Concurrency-Scope` and retry with jittered backoff - `500 UNKNOWN_ERROR` — quote the `X-Correlation-Id` header in a support ticket -## Response headers (on 2xx / 4xx validation / 429) +## Response headers - `X-Correlation-Id` — UUIDv4, matches the stdout log line - `X-RateLimit-Limit` — bucket capacity (default 600 per key) - `X-RateLimit-Remaining` — tokens left in the bucket -- `Retry-After` (429 only) — seconds to wait +- `X-RateLimit-Scope` — rejecting `key`, source `ip`, or process `global` bucket on `RATE_LIMITED` +- `X-Concurrency-Limit` / `X-Concurrency-Remaining` — verification slots for the reported axis after the request reaches the in-flight guard +- `X-Concurrency-Scope` — `key`, trusted source `ip`, or process `global` +- `Retry-After` (429 / 503) — seconds to wait + +Default protection is 600 burst / 10 req/sec per key, 600 / 5 req/sec +per source IP, 5,000 / 100 req/sec per process, 8 concurrent verify handlers per +API key, 16 per trusted source IP, and 32 per process. The key and source shares +make simple credential rotation insufficient to monopolize the process from one +network source. Fly Proxy separately +limits the complete service to 80 soft / 120 hard concurrent requests per +machine. One million requests per day average about 11.6 req/sec before peaks, +so apps at that scale must coordinate capacity or self-host rather than +assuming DAU implies safe request volume. ## Docs @@ -2149,7 +2169,7 @@ Harmonized `state` values (truthy `isValid`): `ENTITLED`, - [/docs/verification/google](https://kit.openiap.dev/docs/verification/google) — package name, service account JSON - [/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, and Amazon RVS payloads -- [/docs/operations](https://kit.openiap.dev/docs/operations) — rate limits, logs, `/health`, graceful shutdown +- [/docs/operations](https://kit.openiap.dev/docs/operations) — fair use, capacity, rate and concurrency limits, logs, `/health`, graceful shutdown - [openiap.dev/docs/webhooks](https://openiap.dev/docs/webhooks) — operator setup steps for inbound Apple ASN v2 and Google RTDN lifecycle delivery - [/docs/ai-assistants](https://kit.openiap.dev/docs/ai-assistants) — how to point Codex / Claude / Cursor / etc. at this file - [/docs/ai-assistants/codex-plugin](https://kit.openiap.dev/docs/ai-assistants/codex-plugin) — Codex plugin setup and self-hosted IAPKit MCP server option diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt index ef6c44fc7..bec408dda 100644 --- a/packages/docs/public/llms.txt +++ b/packages/docs/public/llms.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Full Reference: https://openiap.dev/llms-full.txt -> Generated: 2026-08-01T17:31:39.495Z +> Generated: 2026-08-01T22:00:58.764Z ## Installation diff --git a/packages/docs/src/pages/docs/kit-backend.tsx b/packages/docs/src/pages/docs/kit-backend.tsx index dddf3c571..70a8969ae 100644 --- a/packages/docs/src/pages/docs/kit-backend.tsx +++ b/packages/docs/src/pages/docs/kit-backend.tsx @@ -741,6 +741,87 @@ async function refreshEntitlements( +
+ + Hosted capacity and high-volume apps + +

+ The official hosted IAPKit service is open-source infrastructure + shared by the OpenIAP community. It is free under fair-use safeguards + and operated on a best-effort basis; it is not unlimited capacity and + does not include dedicated resources or an SLA. +

+

+ Plan from request frequency and peak concurrency, not DAU alone. One + million users making one hosted request per day already averages about{' '} + 11.6 requests per second, before cold-start, + release-day, or notification-driven peaks. That average exceeds the + hosted default per-key steady rate of 10 requests per second. +

+

+ Hosted purchase verification also limits work already in progress to{' '} + + 8 handlers per API key, 16 per trusted source IP, and 32 per process + + . The key and source shares make simple credential rotation + insufficient to monopolize the process from one network source; + requests beyond any axis receive 503 SERVICE_BUSY instead + of entering an unbounded server queue. +

+ +
+

+ Contact us before a high-volume production launch.{' '} + If your organization expects to consume a meaningful share of hosted + capacity, we ask it to help fund server expansion, monitoring, + security, and load testing through{' '} + + GitHub Sponsors + {' '} + or{' '} + + OpenCollective + + . Sponsorship supports shared capacity; it does not automatically + reserve dedicated resources or create an SLA. +

+

+ For predictable capacity and full operational control,{' '} + + self-host the MIT-licensed server + + . For capacity planning or a separate written arrangement, contact{' '} + hyo@hyo.dev. +

+
+
+
Product client payloads diff --git a/packages/docs/src/pages/sponsors.tsx b/packages/docs/src/pages/sponsors.tsx index 65b3057de..2ab1cb1c0 100644 --- a/packages/docs/src/pages/sponsors.tsx +++ b/packages/docs/src/pages/sponsors.tsx @@ -117,6 +117,87 @@ function Sponsors() {
+
+

+ Keep Hosted IAPKit Shared and Sustainable +

+
+

+ The official kit.openiap.dev service runs the + open-source IAPKit backend as shared infrastructure for the whole + ecosystem. It is free under fair-use safeguards, best-effort, and + intentionally available to developers who cannot operate a + receipt-validation server themselves. +

+

+ If your organization expects sustained high volume or would use a + meaningful share of that capacity, we ask you to contact us before + launch and help fund the servers, monitoring, security, and load + testing your traffic requires. You can contribute through{' '} + + GitHub Sponsors + {' '} + or{' '} + + OpenCollective + + . +

+

+ Sponsorship strengthens shared capacity for everyone; it does not + automatically buy unlimited usage, dedicated resources, or an SLA. + Teams that need predictable scaling or full operational control + can{' '} + + self-host the MIT-licensed server + {' '} + or contact{' '} + + hyo@hyo.dev + {' '} + about a separate written arrangement. +

+
+
+

Why AI Can't Replace This Work @@ -251,7 +332,8 @@ function Sponsors() { > GitHub Sponsors is the primary funding channel. Tiers scale from individual contributors to companies shipping OpenIAP in - production — details are on the GitHub page. + production. OpenCollective is also available for transparent + community funding.

Sponsor on GitHub + { + e.currentTarget.style.transform = 'translateY(-2px)'; + e.currentTarget.style.boxShadow = + '0 4px 12px rgba(0, 0, 0, 0.18)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = + '0 2px 8px rgba(0, 0, 0, 0.12)'; + }} + > + Support on OpenCollective + # RATE_LIMIT_GLOBAL_CAPACITY=5000 # RATE_LIMIT_GLOBAL_REFILL_PER_SEC=100 +# Expensive purchase-verification handlers allowed to wait on Convex or an +# upstream store at the same time in one process. Excess requests return +# 503 SERVICE_BUSY with Retry-After instead of being queued in memory. +# Default: 32. Keep this below the Fly proxy hard concurrency limit. +# VERIFY_MAX_IN_FLIGHT=32 +# Maximum verification handlers one API key may occupy in this process. +# Default: 8. Keep this at or below VERIFY_MAX_IN_FLIGHT. +# VERIFY_MAX_IN_FLIGHT_PER_KEY=8 +# Maximum handlers one trusted source IP may occupy. This makes API-key +# rotation insufficient to monopolize the process. Default: 16. +# Only Fly's `fly-client-ip` is trusted, so a deployment that does not run +# behind Fly resolves every caller to one shared "unknown" source. This axis +# then acts as a second process-wide cap: raise it to VERIFY_MAX_IN_FLIGHT to +# recover the intended global concurrency when self-hosting off Fly. +# VERIFY_MAX_IN_FLIGHT_PER_IP=16 +# Retry hint for a full verification process. Default: 1 second. +# VERIFY_BUSY_RETRY_AFTER_SEC=1 + # ──────────────────────────────────────────────────────────────── # Mixpanel (product analytics + retention dashboards at mixpanel.com). # VITE_-prefixed → baked into the SPA bundle, so the value is public. diff --git a/packages/kit/COST-SAFETY.md b/packages/kit/COST-SAFETY.md index b5a98f8c7..959802f54 100644 --- a/packages/kit/COST-SAFETY.md +++ b/packages/kit/COST-SAFETY.md @@ -1,6 +1,6 @@ # IAPKit cost and abuse safety -This document records the cost model for the public IAPKit API as of July 28, 2026. It is an operational estimate, not an invoice forecast: actual Convex +This document records the cost model for the public IAPKit API as of August 2, 2026 in the project's Asia/Seoul timezone. It is an operational estimate, not an invoice forecast: actual Convex database I/O depends on each project's document sizes and should be measured from production function logs. @@ -58,6 +58,16 @@ in-memory token bucket before calling Convex: - payload catalog: a separate weighted limiter charges one token per requested product, so a 50-item body page costs 50 tokens. +Fly Proxy limits the complete HTTP service to 80 soft / 120 hard concurrent +requests per machine. Inside the app, purchase verification has a stricter +default of 8 in-flight handlers per API key, 16 per trusted source IP, and 32 +per process. The key and source shares prevent simple key rotation from +occupying every shared verification slot from one network source. +When any axis reaches its configured limit, IAPKit returns `503 SERVICE_BUSY` with +`Retry-After` and `X-Concurrency-Scope` instead of retaining request bodies and +sockets in an unbounded queue. The slot is released in a `finally` block on +both normal responses and downstream errors; idle key entries are deleted. + Stores use a 15-minute idle TTL and LRU eviction. Key and IP stores are capped at 10,000 entries, so random-key/IP churn cannot grow process memory without bound. Rejections return `429 RATE_LIMITED`, `Retry-After`, @@ -73,6 +83,12 @@ cross-machine hard brake; a distributed globally consistent edge limit would require additional infrastructure and is not justified for the current single-machine deployment. +These controls reduce the blast radius of buggy clients, traffic spikes, and +common resource-exhaustion attempts. They are defense in depth, not a guarantee +that a public endpoint is immune to DDoS. Operators may still block abusive +sources at the platform edge and should review Fly and Convex telemetry during +an incident. + The Convex deployment URL is public configuration, and the legacy public `subscriptionStatus` / `entitlements` functions remain callable with a publishable key for rolling-deploy and rollback compatibility. Such direct @@ -157,6 +173,12 @@ pathological ceiling is 201 million small row reads across those requests. Rate limiting is the primary protection against an app polling continuously; `304` responses do not make that request free. +One million requests spread evenly across one day average approximately 11.6 +requests/second, already above the hosted default per-key steady rate of 10 +requests/second before peak clustering. High-volume consumers must reduce call +frequency, coordinate shared capacity before launch, or self-host with limits +sized from measured latency and peak concurrency. + Pricing references: [Convex pricing](https://www.convex.dev/pricing), [Convex usage limits](https://docs.convex.dev/production/usage-limits), and @@ -188,5 +210,6 @@ Monitor `function_execution` logs for `database_io_read_bytes`, fetching payload catalogs, high `304`-free direct payload traffic, rate-limit scope saturation, and unexpected nested purchase-verification calls. -No additional Fly machine, deployment, paid cache, or rate-limit service is -introduced by this feature. +No additional Fly machine, deployment, paid cache, or distributed rate-limit +service is introduced by these source guardrails. Increasing hosted capacity is +an explicit operational and funding decision rather than an automatic promise. diff --git a/packages/kit/README.md b/packages/kit/README.md index 717ccfc4e..db3be91aa 100644 --- a/packages/kit/README.md +++ b/packages/kit/README.md @@ -14,8 +14,11 @@ don't control. > IAPKit is provided **as-is** with best-effort support. There is no > SLA on the hosted instance. If you need guaranteed response times, -> self-host from `packages/kit/` or sponsor at -> [openiap.dev/sponsors](https://openiap.dev/sponsors). +> self-host from `packages/kit/` or contact the maintainers before launch. +> Organizations expecting to use a significant share of hosted capacity are +> asked to help fund shared infrastructure through +> [GitHub Sponsors](https://github.com/sponsors/hyodotdev) or +> [OpenCollective](https://opencollective.com/openiap). ## What's Inside @@ -39,7 +42,7 @@ One package, one binary, one Fly.io app. - **Publishable and secret API keys per project** with server-enforced scopes and usage telemetry - **Organization + project multi-tenancy** via Convex -- **Free for everyone** — no paywall, no usage caps. Sustained by sponsors at [openiap.dev/sponsors](https://openiap.dev/sponsors) +- **Free under fair-use limits** — no billing meter; shared hosted capacity is protected by rate, replay, size, and concurrency safeguards and sustained by community sponsorship - **Email OTP (Resend) + GitHub OAuth** via `@convex-dev/auth` - **OpenAPI spec** auto-generated by `hono-openapi` - **Codex / Claude Code MCP plugin endpoint** at `/mcp` for IAPKit project inspection, revenue questions, product management, and store-sync workflows @@ -139,6 +142,37 @@ bun run build:all # Vite build + Bun compile → ./openiap-kit-server ## Operations +### Hosted fair use and capacity planning + +The official hosted service is a shared, community-funded resource for the +OpenIAP ecosystem. It is free to use without a request-based billing meter, but +it is not unlimited infrastructure and does not include dedicated capacity or +an SLA. Clients must cache stable reads, avoid continuous polling, and honor +`429 Retry-After` and `503 Retry-After` responses. + +Traffic that looks like abusive automation, denial-of-service activity, or +deliberate safeguard evasion may be throttled or blocked to protect other +users. These application and proxy controls are defense in depth, not a claim +that any public service can be made immune to DDoS attacks. + +Capacity planning must use requests, peaks, and store-verification frequency — +not DAU alone. For example, **1 million users making one hosted request per day +average about 11.6 requests/second**, already above the default per-key steady +rate of 10 requests/second before launch-time or notification-driven peaks. +Verify purchases only after purchase or restore, persist entitlement snapshots, +coalesce concurrent refreshes, and revalidate only when the snapshot is stale +or the user explicitly refreshes it. + +If an organization expects sustained high volume or a meaningful share of the +hosted service, contact [hyo@hyo.dev](mailto:hyo@hyo.dev) before production +launch. We ask organizations at that scale to help fund server capacity, +monitoring, security, and load testing through +[GitHub Sponsors](https://github.com/sponsors/hyodotdev) or +[OpenCollective](https://opencollective.com/openiap). Sponsorship supports the +shared service; it does not automatically reserve capacity or create an SLA. +Self-host this MIT-licensed server when you need predictable scaling, dedicated +resources, or full operational control. + ### Health check `GET /health` returns public operational metadata without touching Convex: @@ -193,6 +227,22 @@ the exact same receipt: **30-request burst, ~1/min sustained**, plus a invalid. Those paths return `429 DUPLICATE_PAYLOAD` or `429 REPEATED_FAILURE` with `Retry-After`. +Accepted verification work is also capped at **8 concurrent handlers per API +key, 16 per trusted source IP, and 32 per process** by default. The key and +source shares make rotating credentials insufficient to monopolize the process +from one network source. When Convex or an upstream store is slow and any axis +is full, the server returns `503 SERVICE_BUSY` with `Retry-After`, +`X-Concurrency-Limit`, `X-Concurrency-Remaining`, and `X-Concurrency-Scope` +instead of retaining an unbounded in-process queue. Tune self-hosted instances +with `VERIFY_MAX_IN_FLIGHT`, `VERIFY_MAX_IN_FLIGHT_PER_KEY`, and +`VERIFY_MAX_IN_FLIGHT_PER_IP`. The source axis trusts only Fly's +`fly-client-ip`, so a deployment that does not run behind Fly resolves every +caller to one shared `unknown` source and that axis becomes a second +process-wide cap; raise `VERIFY_MAX_IN_FLIGHT_PER_IP` to +`VERIFY_MAX_IN_FLIGHT` to recover the intended global concurrency there. Fly +Proxy separately protects the complete HTTP service at 80 soft / 120 hard +concurrent requests per machine. + Key/IP bucket stores have a 15-minute idle TTL and are bounded (default **10,000 entries**) with LRU eviction. An attacker churning random API keys or addresses past the parse-only middleware cannot grow the maps without bound. @@ -475,12 +525,15 @@ Omitting Sentry or Mixpanel is fine; the SPA skips those integrations. Server-side runtime secrets (read by the compiled Bun binary at boot) are set once with `flyctl secrets set`: -| Secret | Purpose | -| ------------------------- | --------------------------------------------------- | -| `CONVEX_URL` | Convex HTTP client endpoint for the Hono server | -| `SENTRY_DSN` | Server-side Sentry (`@sentry/bun`) — optional | -| `SENTRY_SEND_DEFAULT_PII` | `true` / `false` (default `false`) | -| `RATE_LIMIT_*` | Override bounded key/IP/process rate-limit defaults | +| Secret | Purpose | +| ------------------------------ | --------------------------------------------------- | +| `CONVEX_URL` | Convex HTTP client endpoint for the Hono server | +| `SENTRY_DSN` | Server-side Sentry (`@sentry/bun`) — optional | +| `SENTRY_SEND_DEFAULT_PII` | `true` / `false` (default `false`) | +| `RATE_LIMIT_*` | Override bounded key/IP/process rate-limit defaults | +| `VERIFY_MAX_IN_FLIGHT` | Override concurrent purchase-verification work | +| `VERIFY_MAX_IN_FLIGHT_PER_KEY` | Override one key's share of verification work | +| `VERIFY_MAX_IN_FLIGHT_PER_IP` | Override one source IP's share of verification work | ### Automated (CI on push to `main`) @@ -530,9 +583,15 @@ IAPKit is one of several ways to use the OpenIAP specification: | -------------------------------- | -------------------------------------------------------------------------- | ------------------------------- | | **Client libraries** (free, MIT) | expo-iap / react-native-iap / flutter_inapp_purchase / kmp-iap / godot-iap | same | | **Native modules** (free, MIT) | openiap-apple, openiap-google | same | -| **Receipt validation backend** | IAPKit SaaS — free for everyone | Deploy this repo yourself (MIT) | - -The specification ([openiap.dev](https://openiap.dev)) is 100% open source. IAPKit's hosted service is free for every developer; infrastructure is sustained by community sponsorship at [openiap.dev/sponsors](https://openiap.dev/sponsors). You can always run this repo on your own infrastructure and pay no recurring fees. +| **Receipt validation backend** | IAPKit — free on shared capacity under fair-use safeguards | Deploy this repo yourself (MIT) | + +The specification ([openiap.dev](https://openiap.dev)) is 100% open source. +IAPKit's hosted service is free for every developer under fair-use safeguards; +infrastructure is sustained through +[GitHub Sponsors](https://github.com/sponsors/hyodotdev), +[OpenCollective](https://opencollective.com/openiap), and other community +support listed at [openiap.dev/sponsors](https://openiap.dev/sponsors). You can +always run this repo on your own infrastructure and pay no IAPKit license fee. ## License diff --git a/packages/kit/SECURITY.md b/packages/kit/SECURITY.md index 8b400240d..68b2f9346 100644 --- a/packages/kit/SECURITY.md +++ b/packages/kit/SECURITY.md @@ -38,14 +38,14 @@ Out of scope: - Findings that require an attacker to already have full control of a maintainer's machine or Convex dashboard credentials. -- Denial-of-service via raw request volume that the edge defenses - on `/api/v1/*` are designed to absorb: the per-API-key burst limiter - (600 req/min sustained, 600 burst), the per-(API key, payload) - replay-guard (~30 burst, ~1/min sustained for the same receipt), - and the valibot format gates that 400 obviously-malformed payloads. - If you can defeat any of those layers — for example, by getting - the Convex action invoked for a payload that should have been - rejected at the edge — that's in scope. +- Reports that merely send raw request volume without demonstrating a bypass, + amplification issue, or other vulnerability. IAPKit uses defense-in-depth: + bounded key/IP/process rate limits, per-payload replay protection, request and + field-size gates, key/IP/process verification concurrency limits, and Fly + Proxy concurrency limits. These controls reduce impact; they do not guarantee + that a public endpoint can absorb every volumetric DDoS attack. A way to evade + a layer, cheaply amplify work, exhaust capacity below its intended threshold, + or reach Convex/store work with a request that should be rejected is in scope. - Issues that only affect a fork running with modified code. ## Coordinated disclosure diff --git a/packages/kit/convex/plans.ts b/packages/kit/convex/plans.ts index 903b694a1..49f1f8172 100644 --- a/packages/kit/convex/plans.ts +++ b/packages/kit/convex/plans.ts @@ -3,8 +3,9 @@ import { v } from "convex/values"; /** * IAPKit is free-for-everyone with no monthly cap. Abuse protection * lives at the edge (format validation, replay-guard, per-key burst - * limit in `server/api/v1/`) — not as a monthly hard stop, so - * legitimate high-volume apps are never blocked for being successful. + * and concurrency limits in `server/api/v1/`) — not as a monthly hard stop. + * Monthly accounting never blocks a request, while the fair-use edge + * safeguards may throttle any plan to protect the shared service. * * `monthlyRequestCount` on organizations stays for dashboard display * and informal telemetry. `monthlyRequestLimit` is kept as a soft @@ -22,7 +23,7 @@ export const SUBSCRIPTION_PLANS = { developer: { id: "developer" as const, label: "Developer", - description: "Free for all developers", + description: "Free for all developers under hosted fair-use safeguards", monthlyRequestLimit: SPONSOR_CTA_THRESHOLD, requiresPayment: false, }, diff --git a/packages/kit/fly.toml b/packages/kit/fly.toml index 023876e01..5e3b578b4 100644 --- a/packages/kit/fly.toml +++ b/packages/kit/fly.toml @@ -12,6 +12,11 @@ primary_region = 'iad' min_machines_running = 1 processes = ['app'] + [http_service.concurrency] + type = 'requests' + soft_limit = 80 + hard_limit = 120 + [[vm]] size = 'shared-cpu-1x' memory = '512mb' diff --git a/packages/kit/public/llms-full.txt b/packages/kit/public/llms-full.txt index 089bcabb1..9407cf8d7 100644 --- a/packages/kit/public/llms-full.txt +++ b/packages/kit/public/llms-full.txt @@ -284,7 +284,8 @@ guidance, webhook simulation, and project inspection. Setup guides: | 401 | `MISSING_API_KEY` | No `Authorization` header | | 403 | `INSUFFICIENT_SCOPE` | Publishable key used for an admin operation | | 403 | `INVALID_API_KEY` | Wrong scheme or malformed key (format only) | -| 429 | `RATE_LIMITED` | Per-key bucket empty; honor `Retry-After` | +| 429 | `RATE_LIMITED` | Key, IP, or process bucket empty; inspect `X-RateLimit-Scope` and honor `Retry-After` | +| 503 | `SERVICE_BUSY` | Verification concurrency is full; retry later | | 500 | `UNKNOWN_ERROR` | Server-side failure; include `X-Correlation-Id` | Error body shape: @@ -307,23 +308,50 @@ per-store docs pages. ## Response headers -Every authenticated response (2xx, validation 4xx, 429) carries: +Every verification request that passes bearer-token shape validation receives +`X-Correlation-Id`. Requests that reach the multi-axis rate limiter, including +application-generated 503 responses, also carry: -- `X-Correlation-Id` — UUIDv4, matches the stdout log line - `X-RateLimit-Limit` — bucket capacity for this API key - `X-RateLimit-Remaining` — tokens left in the bucket -On 429 the response also carries `Retry-After` in seconds. 401 / 403 +`RATE_LIMITED` responses also carry `X-RateLimit-Scope`, identifying the +rejecting `key`, source `ip`, or process `global` bucket. Verification responses +that reach the in-flight guard carry `X-Concurrency-Limit` and +`X-Concurrency-Remaining`; `X-Concurrency-Scope` identifies the reported `key`, +trusted source `ip`, or process-`global` axis. A 429 or application-generated +503 response carries `Retry-After` in seconds. +401 / 403 responses from the auth layer run before rate-limit middleware and do not carry these headers. ## Rate limits -In-memory token bucket keyed on SHA-256(api-key). Defaults: 600-request -burst + 10 req/sec steady state (≈ 600 req/min sustained). Tunable on -self-hosted deployments via `RATE_LIMIT_CAPACITY` and -`RATE_LIMIT_REFILL_PER_SEC`. The Map is capped at 10,000 entries with LRU -eviction (`RATE_LIMIT_MAX_STORE`). +Bounded in-memory token buckets default to 600 burst + 10 req/sec per API +key, 600 + 5 req/sec per source IP, and 5,000 + 100 req/sec per process. +Self-hosted deployments tune the `RATE_LIMIT_*` environment variables. Key/IP +maps are capped at 10,000 entries with TTL cleanup and LRU eviction. + +Purchase verification is additionally capped at 8 in-flight handlers per API +key, 16 per trusted source IP, and 32 per process. The key and source shares +make simple credential rotation insufficient to monopolize the process from +one network source. Excess work returns `503 SERVICE_BUSY` with `Retry-After` +and `X-Concurrency-Scope` rather than queueing request bodies in memory. Tune +with `VERIFY_MAX_IN_FLIGHT`, `VERIFY_MAX_IN_FLIGHT_PER_KEY`, and +`VERIFY_MAX_IN_FLIGHT_PER_IP`. Fly Proxy limits +the complete HTTP service to 80 soft / 120 hard concurrent requests per machine. + +## Hosted fair use and capacity + +The official hosted service is shared, community-funded, free under fair-use +safeguards, and best-effort. It is not unlimited capacity and does not include +dedicated resources or an SLA. One million requests per day average about 11.6 +req/sec before peak clustering, already above the default per-key steady rate. +High-volume apps must cache stable data, coalesce refreshes, honor 429/503 +backoff, and contact hyo@hyo.dev before launch. Organizations consuming a +meaningful share of capacity are asked to fund shared servers, monitoring, and +security through GitHub Sponsors or OpenCollective. Sponsorship does not +automatically reserve capacity; self-host for dedicated operational control. ## Input size caps diff --git a/packages/kit/public/llms.txt b/packages/kit/public/llms.txt index 54907533b..f436b7b09 100644 --- a/packages/kit/public/llms.txt +++ b/packages/kit/public/llms.txt @@ -8,6 +8,12 @@ IAPKit lives in the OpenIAP monorepo as a Bun + Hono server, Convex backend, and React SPA deployed behind one origin. +The official hosted service is free under fair-use safeguards on shared, +community-funded capacity. It is best-effort, not unlimited or SLA-backed. +High-volume apps should contact hyo@hyo.dev before launch, help fund shared +capacity through GitHub Sponsors or OpenCollective, or self-host the +MIT-licensed server for dedicated capacity. + ## API quick reference Base URL: https://kit.openiap.dev @@ -40,7 +46,7 @@ IAPKit does not relay those events through SSE, WebSockets, push, or long polling. Apps persist only the user-scoped fields they need and conditionally refresh on cold start, stale foreground, or explicit user action. Each refresh still performs one mutation-free indexed Convex query so an expiry with no new -webhook is detected. Respect `429 Retry-After`, coalesce concurrent refreshes, +webhook is detected. Respect the `Retry-After` header on `429` and `503`, coalesce concurrent refreshes, and enforce an app-defined maximum stale age for offline fallback. Secret-key responses are `private, no-store` and omit `ETag`. The current `kitApi.status()` and `kitApi.entitlements()` helpers are unconditional, @@ -114,15 +120,29 @@ Harmonized `state` values (truthy `isValid`): `ENTITLED`, - `403 INSUFFICIENT_SCOPE` — publishable key used for an administrative operation - `403 INVALID_API_KEY` — wrong scheme or malformed key (format check only) - `410 SECRET_API_KEY_IN_URL` — move the secret to `Authorization: Bearer ...` on the canonical route -- `429 RATE_LIMITED` — per-key bucket empty; honor `Retry-After` seconds +- `429 RATE_LIMITED` — API-key, source-IP, or process bucket empty; inspect `X-RateLimit-Scope` and honor `Retry-After` +- `503 SERVICE_BUSY` — the API-key, source-IP, or process verification share is full; inspect `X-Concurrency-Scope` and retry with jittered backoff - `500 UNKNOWN_ERROR` — quote the `X-Correlation-Id` header in a support ticket -## Response headers (on 2xx / 4xx validation / 429) +## Response headers - `X-Correlation-Id` — UUIDv4, matches the stdout log line - `X-RateLimit-Limit` — bucket capacity (default 600 per key) - `X-RateLimit-Remaining` — tokens left in the bucket -- `Retry-After` (429 only) — seconds to wait +- `X-RateLimit-Scope` — rejecting `key`, source `ip`, or process `global` bucket on `RATE_LIMITED` +- `X-Concurrency-Limit` / `X-Concurrency-Remaining` — verification slots for the reported axis after the request reaches the in-flight guard +- `X-Concurrency-Scope` — `key`, trusted source `ip`, or process `global` +- `Retry-After` (429 / 503) — seconds to wait + +Default protection is 600 burst / 10 req/sec per key, 600 / 5 req/sec +per source IP, 5,000 / 100 req/sec per process, 8 concurrent verify handlers per +API key, 16 per trusted source IP, and 32 per process. The key and source shares +make simple credential rotation insufficient to monopolize the process from one +network source. Fly Proxy separately +limits the complete service to 80 soft / 120 hard concurrent requests per +machine. One million requests per day average about 11.6 req/sec before peaks, +so apps at that scale must coordinate capacity or self-host rather than +assuming DAU implies safe request volume. ## Docs @@ -133,7 +153,7 @@ Harmonized `state` values (truthy `isValid`): `ENTITLED`, - [/docs/verification/google](https://kit.openiap.dev/docs/verification/google) — package name, service account JSON - [/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, and Amazon RVS payloads -- [/docs/operations](https://kit.openiap.dev/docs/operations) — rate limits, logs, `/health`, graceful shutdown +- [/docs/operations](https://kit.openiap.dev/docs/operations) — fair use, capacity, rate and concurrency limits, logs, `/health`, graceful shutdown - [openiap.dev/docs/webhooks](https://openiap.dev/docs/webhooks) — operator setup steps for inbound Apple ASN v2 and Google RTDN lifecycle delivery - [/docs/ai-assistants](https://kit.openiap.dev/docs/ai-assistants) — how to point Codex / Claude / Cursor / etc. at this file - [/docs/ai-assistants/codex-plugin](https://kit.openiap.dev/docs/ai-assistants/codex-plugin) — Codex plugin setup and self-hosted IAPKit MCP server option diff --git a/packages/kit/server/api/v1/in-flight-limit.test.ts b/packages/kit/server/api/v1/in-flight-limit.test.ts new file mode 100644 index 000000000..5b2e2481d --- /dev/null +++ b/packages/kit/server/api/v1/in-flight-limit.test.ts @@ -0,0 +1,218 @@ +import { Hono } from "hono"; +import { describe, expect, it } from "vitest"; + +import { inFlightLimitMiddleware, type InFlightState } from "./in-flight-limit"; + +function deferred(): { + promise: Promise; + resolve: () => void; +} { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("inFlightLimitMiddleware", () => { + it("isolates API keys, enforces the global cap, and releases every slot", async () => { + const state: InFlightState = { + active: 0, + byKey: new Map(), + byIp: new Map(), + }; + const gate = deferred(); + const app = new Hono<{ Variables: { apiKeyHash: string } }>(); + + app.use("*", async (c, next) => { + c.set("apiKeyHash", c.req.header("X-Test-Key") ?? "test-key"); + await next(); + }); + + app.post( + "/verify", + inFlightLimitMiddleware({ + maxInFlight: 2, + maxInFlightPerKey: 1, + retryAfterSeconds: 3, + state, + }), + async (c) => { + await gate.promise; + return c.json({ ok: true }); + }, + ); + + const acceptedA = app.request("/verify", { + method: "POST", + headers: { "X-Test-Key": "key-a" }, + }); + await Promise.resolve(); + expect(state.active).toBe(1); + + const rejectedKey = await app.request("/verify", { + method: "POST", + headers: { "X-Test-Key": "key-a" }, + }); + expect(rejectedKey.status).toBe(503); + expect(rejectedKey.headers.get("Retry-After")).toBe("3"); + expect(rejectedKey.headers.get("X-Concurrency-Limit")).toBe("1"); + expect(rejectedKey.headers.get("X-Concurrency-Remaining")).toBe("0"); + expect(rejectedKey.headers.get("X-Concurrency-Scope")).toBe("key"); + expect(await rejectedKey.json()).toMatchObject({ + errors: [{ code: "SERVICE_BUSY" }], + }); + expect(state.active).toBe(1); + + const acceptedB = app.request("/verify", { + method: "POST", + headers: { "X-Test-Key": "key-b" }, + }); + await Promise.resolve(); + expect(state.active).toBe(2); + + const rejectedGlobal = await app.request("/verify", { + method: "POST", + headers: { "X-Test-Key": "key-c" }, + }); + expect(rejectedGlobal.status).toBe(503); + expect(rejectedGlobal.headers.get("X-Concurrency-Limit")).toBe("2"); + expect(rejectedGlobal.headers.get("X-Concurrency-Scope")).toBe("global"); + expect(rejectedGlobal.headers.get("Retry-After")).toBe("3"); + expect(await rejectedGlobal.json()).toMatchObject({ + errors: [{ code: "SERVICE_BUSY" }], + }); + + gate.resolve(); + const [acceptedResponseA, acceptedResponseB] = await Promise.all([ + acceptedA, + acceptedB, + ]); + expect(acceptedResponseA.status).toBe(200); + expect(acceptedResponseB.status).toBe(200); + expect(acceptedResponseA.headers.get("X-Concurrency-Scope")).toBe("key"); + expect(state.active).toBe(0); + expect(state.byKey.size).toBe(0); + expect(state.byIp.size).toBe(0); + }); + + it("prevents one source from rotating keys to occupy the global pool", async () => { + const state: InFlightState = { + active: 0, + byKey: new Map(), + byIp: new Map(), + }; + const gate = deferred(); + const app = new Hono<{ Variables: { apiKeyHash: string } }>(); + app.use("*", async (c, next) => { + c.set("apiKeyHash", c.req.header("X-Test-Key") ?? "test-key"); + await next(); + }); + app.post( + "/verify", + inFlightLimitMiddleware({ + maxInFlight: 4, + maxInFlightPerKey: 1, + maxInFlightPerIp: 2, + getIp: (c) => c.req.header("X-Test-Ip"), + state, + }), + async (c) => { + await gate.promise; + return c.json({ ok: true }); + }, + ); + + const acceptedA = app.request("/verify", { + method: "POST", + headers: { "X-Test-Key": "key-a", "X-Test-Ip": "source-a" }, + }); + const acceptedB = app.request("/verify", { + method: "POST", + headers: { "X-Test-Key": "key-b", "X-Test-Ip": "source-a" }, + }); + await Promise.resolve(); + expect(state.active).toBe(2); + + const rotatedKey = await app.request("/verify", { + method: "POST", + headers: { "X-Test-Key": "key-c", "X-Test-Ip": "source-a" }, + }); + expect(rotatedKey.status).toBe(503); + expect(rotatedKey.headers.get("X-Concurrency-Limit")).toBe("2"); + expect(rotatedKey.headers.get("X-Concurrency-Scope")).toBe("ip"); + expect(await rotatedKey.json()).toMatchObject({ + errors: [{ code: "SERVICE_BUSY" }], + }); + expect(state.active).toBe(2); + + const acceptedOtherSource = app.request("/verify", { + method: "POST", + headers: { "X-Test-Key": "key-c", "X-Test-Ip": "source-b" }, + }); + await Promise.resolve(); + expect(state.active).toBe(3); + + gate.resolve(); + const responses = await Promise.all([ + acceptedA, + acceptedB, + acceptedOtherSource, + ]); + expect(responses.every((response) => response.status === 200)).toBe(true); + expect(state.active).toBe(0); + expect(state.byKey.size).toBe(0); + expect(state.byIp.size).toBe(0); + }); + + it("releases a slot when downstream throws", async () => { + const state: InFlightState = { + active: 0, + byKey: new Map(), + byIp: new Map(), + }; + const app = new Hono<{ Variables: { apiKeyHash: string } }>(); + app.use("*", async (c, next) => { + c.set("apiKeyHash", "error-key"); + await next(); + }); + app.onError((_error, c) => c.json({ ok: false }, 500)); + app.post( + "/verify", + inFlightLimitMiddleware({ maxInFlight: 1, state }), + () => { + throw new Error("upstream failed"); + }, + ); + + const response = await app.request("/verify", { method: "POST" }); + + expect(response.status).toBe(500); + expect(state.active).toBe(0); + expect(state.byKey.size).toBe(0); + expect(state.byIp.size).toBe(0); + }); + + it("fails closed when the API key hash middleware is missing", async () => { + const app = new Hono(); + app.post("/verify", inFlightLimitMiddleware({ maxInFlight: 4 }), (c) => + c.json({ ok: true }), + ); + + const response = await app.request("/verify", { method: "POST" }); + + expect(response.status).toBe(500); + expect(response.headers.get("X-Concurrency-Limit")).toBe("4"); + expect(response.headers.get("X-Concurrency-Remaining")).toBe("0"); + expect(response.headers.get("X-Concurrency-Scope")).toBe("global"); + expect(await response.json()).toEqual({ + errors: [ + { + code: "INTERNAL_MISCONFIGURATION", + message: + "Concurrency guard ran before the API key hash was populated.", + }, + ], + }); + }); +}); diff --git a/packages/kit/server/api/v1/in-flight-limit.ts b/packages/kit/server/api/v1/in-flight-limit.ts new file mode 100644 index 000000000..17a16e076 --- /dev/null +++ b/packages/kit/server/api/v1/in-flight-limit.ts @@ -0,0 +1,201 @@ +import { createMiddleware } from "hono/factory"; +import type { Context } from "hono"; + +import { parsePositiveNumber } from "../../utils/env"; +import { getRequestIp, hashApiKey } from "./rate-limit"; + +export interface InFlightState { + active: number; + byKey: Map; + byIp: Map; +} + +export interface InFlightLimitConfig { + /** Maximum number of downstream handlers executing in this process. */ + maxInFlight: number; + /** Maximum handlers one API key may occupy in this process. */ + maxInFlightPerKey: number; + /** Maximum handlers one trusted source IP may occupy in this process. */ + maxInFlightPerIp: number; + /** Retry hint returned when the process is already at capacity. */ + retryAfterSeconds: number; + /** Injectable state for deterministic tests or an isolated route group. */ + state: InFlightState; + /** Injectable trusted-IP resolver for deterministic tests. */ + getIp: (c: Context) => string | undefined; +} + +const DEFAULT_MAX_IN_FLIGHT = Math.floor( + parsePositiveNumber(process.env.VERIFY_MAX_IN_FLIGHT, 32, 1), +); +const DEFAULT_MAX_IN_FLIGHT_PER_KEY = Math.floor( + parsePositiveNumber(process.env.VERIFY_MAX_IN_FLIGHT_PER_KEY, 8, 1), +); +const DEFAULT_MAX_IN_FLIGHT_PER_IP = Math.floor( + parsePositiveNumber(process.env.VERIFY_MAX_IN_FLIGHT_PER_IP, 16, 1), +); +const DEFAULT_RETRY_AFTER_SECONDS = Math.ceil( + parsePositiveNumber(process.env.VERIFY_BUSY_RETRY_AFTER_SEC, 1, 0.001), +); +const sharedVerifyState: InFlightState = { + active: 0, + byKey: new Map(), + byIp: new Map(), +}; + +type InFlightLimitVars = { + apiKeyHash?: string; +}; + +/** + * Bound expensive verification work already executing in this process. + * + * Token buckets cap arrival rate, but a slow upstream store can make accepted + * requests overlap for much longer than one refill interval. This guard stops + * that overlap from growing without bound. It intentionally rejects instead + * of queueing: an in-process queue would retain request bodies and sockets and + * become another memory-exhaustion target during an outage or traffic spike. + */ +export function inFlightLimitMiddleware( + config: Partial = {}, +): ReturnType> { + const maxInFlight = Math.max( + 1, + Math.floor(config.maxInFlight ?? DEFAULT_MAX_IN_FLIGHT), + ); + const maxInFlightPerKey = Math.max( + 1, + Math.min( + maxInFlight, + Math.floor(config.maxInFlightPerKey ?? DEFAULT_MAX_IN_FLIGHT_PER_KEY), + ), + ); + const maxInFlightPerIp = Math.max( + 1, + Math.min( + maxInFlight, + Math.floor(config.maxInFlightPerIp ?? DEFAULT_MAX_IN_FLIGHT_PER_IP), + ), + ); + const retryAfterSeconds = Math.max( + 1, + Math.ceil(config.retryAfterSeconds ?? DEFAULT_RETRY_AFTER_SECONDS), + ); + const state = config.state ?? sharedVerifyState; + const resolveIp = config.getIp ?? getRequestIp; + + return createMiddleware(async (c, next) => { + const apiKeyHash = c.var.apiKeyHash; + if (!apiKeyHash) { + // Without a key hash the key/IP axes cannot be evaluated safely. Report + // the process axis and fail closed while preserving the documented 500 + // response headers for the verification middleware chain. + c.header("X-Concurrency-Limit", String(maxInFlight)); + c.header("X-Concurrency-Remaining", "0"); + c.header("X-Concurrency-Scope", "global"); + return c.json( + { + errors: [ + { + code: "INTERNAL_MISCONFIGURATION", + message: + "Concurrency guard ran before the API key hash was populated.", + }, + ], + }, + 500, + ); + } + + // Fly overwrites `fly-client-ip`; direct/local traffic shares the + // bounded "unknown" source instead of trusting spoofable forwarded headers. + const ipHash = hashApiKey(`ip:${resolveIp(c) ?? "unknown"}`); + const activeForKey = state.byKey.get(apiKeyHash) ?? 0; + const activeForIp = state.byIp.get(ipHash) ?? 0; + const scope = + state.active >= maxInFlight + ? "global" + : activeForIp >= maxInFlightPerIp + ? "ip" + : activeForKey >= maxInFlightPerKey + ? "key" + : null; + + if (scope !== null) { + const limit = + scope === "global" + ? maxInFlight + : scope === "ip" + ? maxInFlightPerIp + : maxInFlightPerKey; + c.header("Retry-After", String(retryAfterSeconds)); + c.header("X-Concurrency-Limit", String(limit)); + c.header("X-Concurrency-Remaining", "0"); + c.header("X-Concurrency-Scope", scope); + return c.json( + { + errors: [ + { + code: "SERVICE_BUSY", + message: + scope === "global" + ? "Shared verification capacity is temporarily full. Retry with jittered backoff, contact OpenIAP before sustained high-volume traffic, or self-host for dedicated capacity." + : scope === "ip" + ? "This network source is using its current share of verification capacity. Retry with jittered backoff, contact OpenIAP before sustained high-volume traffic, or self-host for dedicated capacity." + : "This API key is using its current share of verification capacity. Retry with jittered backoff, contact OpenIAP before sustained high-volume traffic, or self-host for dedicated capacity.", + }, + ], + }, + 503, + ); + } + + state.active += 1; + state.byKey.set(apiKeyHash, activeForKey + 1); + state.byIp.set(ipHash, activeForIp + 1); + + const remainingAxes = [ + { + scope: "key" as const, + limit: maxInFlightPerKey, + remaining: Math.max(0, maxInFlightPerKey - activeForKey - 1), + }, + { + scope: "ip" as const, + limit: maxInFlightPerIp, + remaining: Math.max(0, maxInFlightPerIp - activeForIp - 1), + }, + { + scope: "global" as const, + limit: maxInFlight, + remaining: Math.max(0, maxInFlight - state.active), + }, + ]; + const tightestAxis = remainingAxes.reduce((tightest, axis) => + axis.remaining < tightest.remaining ? axis : tightest, + ); + c.header("X-Concurrency-Limit", String(tightestAxis.limit)); + c.header("X-Concurrency-Remaining", String(tightestAxis.remaining)); + c.header("X-Concurrency-Scope", tightestAxis.scope); + + try { + await next(); + } finally { + // Downstream store/network failures must never leak capacity. Deleting + // idle entries keeps both maps bounded by the global in-flight maximum. + const remainingForKey = (state.byKey.get(apiKeyHash) ?? 1) - 1; + if (remainingForKey <= 0) { + state.byKey.delete(apiKeyHash); + } else { + state.byKey.set(apiKeyHash, remainingForKey); + } + const remainingForIp = (state.byIp.get(ipHash) ?? 1) - 1; + if (remainingForIp <= 0) { + state.byIp.delete(ipHash); + } else { + state.byIp.set(ipHash, remainingForIp); + } + state.active = Math.max(0, state.active - 1); + } + }); +} diff --git a/packages/kit/server/api/v1/rate-limit.ts b/packages/kit/server/api/v1/rate-limit.ts index ddd480840..8ab7abab4 100644 --- a/packages/kit/server/api/v1/rate-limit.ts +++ b/packages/kit/server/api/v1/rate-limit.ts @@ -6,11 +6,10 @@ import { parsePositiveNumber } from "../../utils/env"; // Per-machine, in-memory token bucket protecting /api/v1/* from abuse // (stolen-key replay, buggy client retry loops, DoS on the verification -// pipeline). Sized for legitimate global-app traffic — the ceiling -// needs to be comfortably above what a real app with ~millions of -// DAU would generate so "you went viral" is never blocked, while -// still catching the obvious abuse patterns that the replay-guard and -// format gates miss. +// pipeline). These are fair-use defaults for shared hosted capacity, +// not a promise of unlimited global-app traffic. Large apps must plan +// from peak request rate, contact the maintainers before launch, or +// self-host with limits sized for their own workload. // // Pairs with the per-(key, payload) replay-guard in `replay-guard.ts`: // - This file: "how many verify calls /sec from one API key?" (any payload) @@ -167,13 +166,13 @@ export function tryConsume( return { allowed: false, remaining: 0, retryAfterSec }; } -// Defaults tuned for legitimate global-app traffic: +// Defaults tuned for shared hosted traffic: // - 600 tokens of burst absorbs push-notification-driven startup // storms and retry-after-transient-5xx spikes. -// - 10 tokens/sec refill = 600/min sustained, enough for an app -// with ~millions of DAU doing app-launch entitlement checks. -// An app larger than this should already be in direct contact with -// the maintainer (sponsor candidate at that scale); tune via env. +// - 10 tokens/sec refill = 600/min sustained. One million requests +// per day already average ~11.6/sec before peak clustering, so an +// app at that scale must reduce call frequency, contact OpenIAP for +// shared capacity planning, or self-host and tune via env. const DEFAULT_CAPACITY = parsePositiveNumber( process.env.RATE_LIMIT_CAPACITY, 600, diff --git a/packages/kit/server/api/v1/routes.test.ts b/packages/kit/server/api/v1/routes.test.ts index 19808fffb..a69663048 100644 --- a/packages/kit/server/api/v1/routes.test.ts +++ b/packages/kit/server/api/v1/routes.test.ts @@ -32,12 +32,27 @@ describe("apiRoutes", () => { const body = (await response.json()) as { openapi: string; - paths: Record; + paths: Record< + string, + { post?: { operationId?: string; responses?: Record } } + >; }; expect(body.openapi).toBe("3.1.0"); expect(body.paths["/purchase/verify"]?.post?.operationId).toBe( "verifyPurchase", ); + expect(body.paths["/purchase/verify"]?.post?.responses).toHaveProperty( + "503", + ); + expect( + body.paths["/purchase/verify"]?.post?.responses?.["200"], + ).toHaveProperty("headers.X-Concurrency-Scope"); + expect( + body.paths["/purchase/verify"]?.post?.responses?.["500"], + ).toHaveProperty("headers.X-RateLimit-Limit"); + expect( + body.paths["/purchase/verify"]?.post?.responses?.["500"], + ).toHaveProperty("headers.X-RateLimit-Remaining"); }); it("returns the verified productId from purchase verification", async () => { @@ -61,6 +76,9 @@ describe("apiRoutes", () => { }); expect(response.status).toBe(200); + expect(response.headers.get("X-Concurrency-Limit")).toBe("8"); + expect(response.headers.get("X-Concurrency-Remaining")).toBe("7"); + expect(response.headers.get("X-Concurrency-Scope")).toBe("key"); expect(await response.json()).toEqual({ store: "google", isValid: true, diff --git a/packages/kit/server/api/v1/routes.ts b/packages/kit/server/api/v1/routes.ts index ad4e9fdb0..ef2e81b94 100644 --- a/packages/kit/server/api/v1/routes.ts +++ b/packages/kit/server/api/v1/routes.ts @@ -16,6 +16,7 @@ import { import { apiKeyMiddleware } from "./middleware"; import { getRequestIp, multiAxisRateLimitMiddleware } from "./rate-limit"; import { replayGuardMiddleware } from "./replay-guard"; +import { inFlightLimitMiddleware } from "./in-flight-limit"; import { requestLoggerMiddleware } from "./request-logger"; import { validator } from "./validator"; import { webhooksRoutes } from "./webhooks"; @@ -142,6 +143,27 @@ const commonResponseHeaders = { }, }; +const concurrencyResponseHeaders = { + "X-Concurrency-Limit": { + description: + "Maximum concurrent verification handlers for the axis named by `X-Concurrency-Scope`.", + schema: { type: "integer" as const, minimum: 1 }, + }, + "X-Concurrency-Remaining": { + description: + "Verification slots remaining on the reported axis when this request entered the guard.", + schema: { type: "integer" as const, minimum: 0 }, + }, + "X-Concurrency-Scope": { + description: + "Concurrency axis reported by these headers: `key` for one API key, `ip` for one trusted source IP, or `global` for this process.", + schema: { + type: "string" as const, + enum: ["key", "ip", "global"], + }, + }, +}; + const verifyPurchaseRouteDescription = describeRoute({ operationId: "verifyPurchase", description: @@ -175,9 +197,14 @@ const verifyPurchaseRouteDescription = describeRoute({ "cross-machine cost brake. In addition, per-(API key, payload) " + "replay-guard: the same receipt can be submitted at most 30 times " + "in a burst and ~1/min sustained — cache the previous result on " + - "your side. Exceeding either returns HTTP 429 with a `Retry-After` " + + "your side. Expensive verification work is also bounded to 8 " + + "concurrent requests per API key, 16 per source IP, and 32 per " + + "process by default; " + + "excess work returns " + + "HTTP 503 `SERVICE_BUSY` instead of queueing request bodies in memory. " + + "Exceeding a token or replay limit returns HTTP 429 with a `Retry-After` " + "header. Responses that pass bearer-token shape validation (2xx, " + - "downstream 4xx, 429) carry `X-RateLimit-Limit`, " + + "downstream 4xx, 429, 503) carry `X-RateLimit-Limit`, " + "`X-RateLimit-Remaining`, and " + "`X-Correlation-Id`. 401 / 403 responses from the auth layer run " + "before the rate-limit middleware and do not include those " + @@ -193,7 +220,10 @@ const verifyPurchaseRouteDescription = describeRoute({ responses: { 200: { description: "Successful verification", - headers: commonResponseHeaders, + headers: { + ...commonResponseHeaders, + ...concurrencyResponseHeaders, + }, content: { "application/json": { schema: resolver(verifyPurchaseSuccessResponseSchema), @@ -267,10 +297,35 @@ const verifyPurchaseRouteDescription = describeRoute({ }, }, }, + 503: { + description: + "Shared verification capacity is temporarily full (`SERVICE_BUSY`). " + + "Retry after the seconds in `Retry-After` with jittered backoff. " + + "Sustained high-volume applications should contact OpenIAP before " + + "launch or self-host for dedicated capacity.", + headers: { + "X-Correlation-Id": commonResponseHeaders["X-Correlation-Id"], + "X-RateLimit-Limit": commonResponseHeaders["X-RateLimit-Limit"], + "X-RateLimit-Remaining": commonResponseHeaders["X-RateLimit-Remaining"], + ...concurrencyResponseHeaders, + "Retry-After": { + description: "Seconds to wait before retrying with jittered backoff.", + schema: { type: "integer" as const, minimum: 1 }, + }, + }, + content: { + "application/json": { + schema: resolver(apiErrorResponseSchema), + }, + }, + }, 500: { description: "Unknown error", headers: { "X-Correlation-Id": commonResponseHeaders["X-Correlation-Id"], + "X-RateLimit-Limit": commonResponseHeaders["X-RateLimit-Limit"], + "X-RateLimit-Remaining": commonResponseHeaders["X-RateLimit-Remaining"], + ...concurrencyResponseHeaders, }, content: { "application/json": { @@ -472,6 +527,7 @@ const verifyPurchaseHandler = async ( const verifyRateLimit = multiAxisRateLimitMiddleware(); const verifyRequestLogger = requestLoggerMiddleware(); const verifyReplayGuard = replayGuardMiddleware(); +const verifyInFlightLimit = inFlightLimitMiddleware(); // Middleware order matters: // 1. apiKeyMiddleware — 401/403 before anything expensive. @@ -483,12 +539,14 @@ const verifyReplayGuard = replayGuardMiddleware(); // below hashes the body. // 5. verifyReplayGuard — per-(key, payload) burst cap + 5-minute // negative cooldown after an `isValid: false` from the store. -// 6. verifyPurchaseHandler — the actual Convex call. The verify +// 6. verifyInFlightLimit — bounds accepted verification work already +// waiting on Convex or an upstream store. Rejects instead of queueing. +// 7. verifyPurchaseHandler — the actual Convex call. The verify // action increments the per-org monthly counter for telemetry // (powers the dashboard usage view + sponsor CTA threshold) -// but does NOT enforce a monthly cap — abuse is stopped at -// the edge layers above so legitimate high-volume apps are -// never blocked by raw success. +// but does NOT grant unlimited throughput or enforce billing. Fair-use +// and safety are enforced by the edge layers above; high-volume apps +// must coordinate capacity or self-host. // // Held in a single tuple so `purchase/verify` (canonical) and // `verify-purchase` (compat alias) can't drift on order or contents @@ -499,6 +557,7 @@ const verifyMiddleware = [ verifyRateLimit, validator(verifyPurchaseInputSchema), verifyReplayGuard, + verifyInFlightLimit, verifyPurchaseHandler, ] as const; diff --git a/packages/kit/src/components/FreeTransitionNotice.tsx b/packages/kit/src/components/FreeTransitionNotice.tsx index e6990e7d3..75cf667db 100644 --- a/packages/kit/src/components/FreeTransitionNotice.tsx +++ b/packages/kit/src/components/FreeTransitionNotice.tsx @@ -70,7 +70,7 @@ export function FreeTransitionNotice({

{ - "Thank you for supporting IAPKit. Your subscription has been cancelled and any unused portion refunded in full — there's nothing you need to do. The validation APIs and analytics you were using keep working." + "Thank you for supporting IAPKit. Your subscription has been cancelled and any unused portion refunded in full — there's nothing you need to do. The validation APIs and analytics you were using keep working under the shared hosted service's fair-use safeguards." }

diff --git a/packages/kit/src/content/terms-of-service.md b/packages/kit/src/content/terms-of-service.md index 84e786732..9b1691233 100644 --- a/packages/kit/src/content/terms-of-service.md +++ b/packages/kit/src/content/terms-of-service.md @@ -45,8 +45,10 @@ You agree **not** to: - Use the service for unlawful purposes - Interfere with or disrupt the service +- Generate abusive automated traffic, denial-of-service traffic, or traffic intended to exhaust shared capacity +- Bypass or evade rate limits, replay protection, access controls, or other security safeguards - Attempt to access systems or data you are not authorized to access -- Reverse engineer, decompile, or attempt to derive the service’s source code +- Probe or test non-public infrastructure without prior written authorization - Use the service to store or transmit personal data unless required for your lawful operations We may suspend or terminate access for violations. @@ -89,7 +91,25 @@ We may update, modify, or discontinue parts of the service at any time with or w --- -## 8. Fees and Payment +## 8. Fair Use, Capacity, and Abuse Prevention + +The official hosted IAPKit service is an open-source, community-funded service running on shared infrastructure. It is provided on a best-effort basis and does not include unlimited capacity, dedicated resources, guaranteed throughput, or a service-level agreement unless we agree otherwise in writing. + +We apply fair-use safeguards, including per-key, per-source, and service-wide rate limits, replay protection, request-size limits, and concurrent-request limits. We may change these safeguards when reasonably necessary to protect availability, security, users, upstream store services, or infrastructure costs. + +We may throttle, reject, temporarily suspend, or block traffic that: + +- Threatens service availability or security +- Appears to be abusive automation, denial-of-service activity, credential abuse, or deliberate safeguard evasion +- Uses a disproportionate share of the hosted service in a way that materially affects other users + +If your application is expected to generate sustained high volume or consume a significant share of shared capacity, contact us before production launch. We ask organizations at that scale to help fund shared server capacity, monitoring, and security through [GitHub Sponsors](https://github.com/sponsors/hyodotdev), [OpenCollective](https://opencollective.com/openiap), or a separate written arrangement. Sponsorship supports the community service but does not by itself reserve capacity or create an SLA. + +The server is MIT-licensed and may be [self-hosted](https://github.com/hyodotdev/openiap/tree/main/packages/kit#deployment-convex--flyio) when you need dedicated capacity, predictable scaling, or full operational control. + +--- + +## 9. Fees and Payment If applicable: @@ -101,22 +121,15 @@ Failure to pay may result in suspension or termination of your account. --- -## 9. Intellectual Property +## 10. Intellectual Property and Open-Source License -All rights, title, and interest in the service, including: +The IAPKit server source code is available under the repository's MIT License. These Terms do not restrict rights granted by that open-source license. -- Software -- Documentation -- APIs -- Logos and branding - -remain the exclusive property of Hyo Dev. - -You are granted a limited, non-exclusive, non-transferable license to use the service in accordance with these Terms. +Hyo Dev and OpenIAP retain their rights in trademarks, logos, branding, the operated hosted service, and any non-public systems or materials. Your access to the official hosted service is limited, non-exclusive, and subject to these Terms. --- -## 10. Termination +## 11. Termination We may suspend or terminate your access if: @@ -133,7 +146,7 @@ Upon termination: --- -## 11. Disclaimers +## 12. Disclaimers The service is provided **“as is”** and **“as available.”** We disclaim all warranties, express or implied, including: @@ -146,7 +159,7 @@ We do not guarantee accuracy of validation results beyond what is provided by th --- -## 12. Limitation of Liability +## 13. Limitation of Liability To the fullest extent permitted by law: @@ -155,7 +168,7 @@ To the fullest extent permitted by law: --- -## 13. Indemnification +## 14. Indemnification You agree to indemnify and hold harmless Hyo Dev from any claims, damages, or losses arising from: @@ -165,14 +178,14 @@ You agree to indemnify and hold harmless Hyo Dev from any claims, damages, or lo --- -## 14. Governing Law +## 15. Governing Law These Terms are governed by the laws of **Sweden**, without regard to conflict of law principles. Any disputes shall be resolved in the courts of Sweden. --- -## 15. Changes to the Terms +## 16. Changes to the Terms We may update these Terms occasionally. If we make material changes, we will notify you via email or website notice. @@ -180,8 +193,10 @@ Continued use of the service constitutes acceptance of updated Terms. --- -## 16. Contact Us +## 17. Contact Us For questions about these Terms, contact: **Hyo Dev** + +Email: [hyo@hyo.dev](mailto:hyo@hyo.dev) diff --git a/packages/kit/src/pages/auth/organization/usage.tsx b/packages/kit/src/pages/auth/organization/usage.tsx index b81094b6b..ce8456a79 100644 --- a/packages/kit/src/pages/auth/organization/usage.tsx +++ b/packages/kit/src/pages/auth/organization/usage.tsx @@ -50,7 +50,7 @@ export default function OrganizationUsagePage() {

{"Usage"}

{ - "Track validation usage and stored receipt analytics. Validation and analytics are free." + "Track validation usage and stored receipt analytics. Hosted IAPKit is free under fair-use limits on shared community infrastructure." }

@@ -84,26 +84,50 @@ export default function OrganizationUsagePage() {

- {"Support OpenIAP"} + {"Planning significant traffic?"}

{ - "Validation and analytics stay free for every developer. If your team or company depends on them, consider supporting the project so we can keep the core service running for thousands of indie developers." + "Hosted IAPKit is a shared, best-effort service rather than unlimited or SLA-backed infrastructure. If your organization expects to consume a meaningful share of capacity, contact us before launch and help fund the servers, monitoring, and security the ecosystem depends on." }

{ - "AI-assisted workflows may later use separate usage-based pricing because model token costs are real infrastructure costs." + "Sponsorship supports shared capacity; it does not automatically reserve dedicated resources or an SLA. Self-host the MIT-licensed server when you need predictable capacity and full operational control." }

-
- {"Become a sponsor"} - +
+ + {"Sponsor on GitHub"} + + + {"OpenCollective"} + + + {"Self-host IAPKit"} + +
+

+ {"Capacity planning: "} + + hyo@hyo.dev + +

diff --git a/packages/kit/src/pages/blog/iapkit-joins-openiap.tsx b/packages/kit/src/pages/blog/iapkit-joins-openiap.tsx index 0f0d64cef..8e7e406a5 100644 --- a/packages/kit/src/pages/blog/iapkit-joins-openiap.tsx +++ b/packages/kit/src/pages/blog/iapkit-joins-openiap.tsx @@ -8,7 +8,7 @@ const SLUG = "iapkit-joins-openiap"; const FAQ: Array<{ q: string; a: string }> = [ { q: "Which parts of IAPKit are free?", - a: "Receipt validation and analytics are free for all developers. No credit card, no monthly plan, and no validation paywall. AI-assisted workflows may later use separate usage-based pricing because model token costs are real infrastructure costs.", + a: "Receipt validation and analytics are free for all developers under the hosted service's fair-use safeguards. There is no credit card, monthly validation plan, or validation paywall. The shared service is best-effort rather than unlimited or SLA-backed; AI-assisted workflows may later use separate usage-based pricing because model token costs are real infrastructure costs.", }, { q: "Why is IAPKit joining OpenIAP?", @@ -20,7 +20,11 @@ const FAQ: Array<{ q: string; a: string }> = [ }, { q: "How can I support IAPKit and OpenIAP?", - a: "Sponsor OpenIAP at any tier ($25 / $100 / $300 / $500 / $1,000) via PayPal or GitHub Sponsors at openiap.dev/sponsors. Sponsors are permanently listed on the sponsors page.", + a: "Support OpenIAP through GitHub Sponsors, OpenCollective, or the other options at openiap.dev/sponsors. Contributions fund the shared servers, security, monitoring, documentation, and cross-platform maintenance the ecosystem depends on.", + }, + { + q: "What if my app expects high traffic?", + a: "Contact hyo@hyo.dev before production launch. Organizations expecting to consume a meaningful share of the shared hosted capacity are asked to help fund its expansion. Sponsorship does not automatically reserve capacity or create an SLA; self-host the MIT-licensed server when you need dedicated, predictable capacity.", }, { q: "Has the domain changed?", @@ -211,10 +215,11 @@ export default function IapkitJoinsOpenIap() { verification flow.
  • - - Same access for indie projects and large commercial apps - {" "} - — validation and analytics cost the same: nothing. + Free access under shared fair-use safeguards. Indie + projects and commercial apps use the same hosted APIs without a + validation paywall. High-volume teams should coordinate capacity, + support the shared infrastructure, or self-host for dedicated + resources.
  • Sustainable AI features. Workflows that call @@ -230,7 +235,8 @@ export default function IapkitJoinsOpenIap() {
  • OpenIAP Sponsorship. Support OpenIAP at any tier - ($25 / $100 / $300 / $500 / $1,000) via PayPal or GitHub Sponsors at{" "} + through GitHub Sponsors, OpenCollective, PayPal, or the other + options listed at{" "}
  • Reuse the cached body on 304, replace it on{" "} - 200, and respect 429 Retry-After. + 200, and respect the Retry-After header on{" "} + 429 or 503.
  • Key local storage by IAPKit project and opaque user ID, and clear it @@ -312,7 +313,7 @@ async function refreshEntitlements( await persistSnapshot(refreshed); return refreshed.snapshot; } - if (response.status === 429) { + if (response.status === 429 || response.status === 503) { scheduleRetry(response.headers.get("Retry-After")); if (cachedForScope && canUseCachedSnapshot()) { return cachedForScope.snapshot; @@ -495,7 +496,11 @@ async function refreshEntitlements(

    Response headers

    -

    Every authenticated response (2xx, validation 4xx, 429) carries:

    +

    + Verification requests that pass bearer-token shape validation carry a + correlation ID. Requests that reach the multi-axis rate limiter also + carry its limit and remaining-token headers: +

    - On 429 the response also carries Retry-After in seconds. + Verification responses that reach the in-flight guard also carry{" "} + X-Concurrency-Limit and{" "} + X-Concurrency-Remaining. X-Concurrency-Scope{" "} + identifies the reported API-key, trusted source-IP, or process-global + axis. A RATE_LIMITED response names its key, IP, or process + bucket in X-RateLimit-Scope. A 429 or application-generated + 503 response carries Retry-After in seconds.

    401 / 403 responses from the auth layer run before the rate-limit @@ -587,10 +598,21 @@ async function refreshEntitlements( REPEATED_FAILURE - Per-key or per-payload guard rejected the request; check + RATE_LIMITED names the rejecting API-key, source-IP, or process + bucket in X-RateLimit-Scope. DUPLICATE_PAYLOAD and + REPEATED_FAILURE are per-(key, payload) replay guards. Check Retry-After. + + 503 + SERVICE_BUSY + + The API-key, trusted source-IP, or process share has no + verification slot available; inspect X-Concurrency-Scope and + retry with jittered backoff after Retry-After. + + 500 UNKNOWN_ERROR diff --git a/packages/kit/src/pages/docs/sections/operations.tsx b/packages/kit/src/pages/docs/sections/operations.tsx index d7ce830b9..758585ced 100644 --- a/packages/kit/src/pages/docs/sections/operations.tsx +++ b/packages/kit/src/pages/docs/sections/operations.tsx @@ -7,16 +7,16 @@ export default function OperationsPage() {

    Rate limits

    - /v1/purchase/verify is protected by an in-memory - token-bucket keyed on a SHA-256 hash of the API key. Defaults: - 600-request burst, 10 req/sec steady state — - equivalently 600 req/min sustained. Self-hosted deployments can tune via{" "} - RATE_LIMIT_CAPACITY and{" "} - RATE_LIMIT_REFILL_PER_SEC. + Publishable-key routes are protected by bounded, in-memory token + buckets. Defaults are 600 burst / 10 req/sec per API + key, 600 burst / 5 req/sec per source IP, and{" "} + 5,000 burst / 100 req/sec for the whole process. + Self-hosted deployments can tune the RATE_LIMIT_*{" "} + environment variables.

    When the bucket empties, IAPKit returns 429 RATE_LIMITED{" "} @@ -42,6 +42,80 @@ export default function OperationsPage() {

    +

    + Concurrent verification capacity +

    +

    + Arrival rate and work already in progress are different limits. A slow + Apple, Google, Amazon, Meta, or Convex response can make accepted + verifications overlap, so each API key may occupy at most{" "} + 8 verification handlers, each trusted source IP at most{" "} + 16, and each process at most 32 by + default. The key and source shares make simple credential rotation + insufficient to monopolize the process from one network source. Excess + work is not queued in memory; it returns 503 SERVICE_BUSY{" "} + with Retry-After, X-Concurrency-Limit,{" "} + X-Concurrency-Remaining, and{" "} + X-Concurrency-Scope. +

    +

    + Self-hosters can tune VERIFY_MAX_IN_FLIGHT,{" "} + VERIFY_MAX_IN_FLIGHT_PER_KEY, and{" "} + VERIFY_MAX_IN_FLIGHT_PER_IP. Fly Proxy also uses request + concurrency limits for the whole HTTP service (80 soft, 120 hard per + machine) so static, read, and verification traffic cannot create an + unbounded number of requests inside one 512 MB machine. +

    + +

    + Hosted fair use and high-volume apps +

    +

    + Hosted IAPKit is open-source infrastructure shared by the OpenIAP + community. It is free without a request billing meter, best-effort, and + protected by fair-use safeguards; it is not unlimited capacity and does + not include dedicated resources or an SLA. +

    + +

    + One million users making one request per day average about 11.6 + requests per second, already above the hosted default per-key steady + rate before launch-time or notification-driven peaks. Cache stable + data, coalesce refreshes, verify only after purchase or restore, and + honor the Retry-After header on 429 and{" "} + 503 with jittered backoff. +

    +
    +

    + If your organization expects sustained high volume or a meaningful share + of hosted capacity, contact{" "} + + hyo@hyo.dev + {" "} + before launch. We ask organizations at that scale to help fund shared + capacity, monitoring, security, and load testing through{" "} + + GitHub Sponsors + {" "} + or{" "} + + OpenCollective + + . Sponsorship strengthens the shared service; it does not automatically + reserve capacity or create an SLA. For predictable scaling, self-host + the MIT-licensed server. +

    +

    Correlation IDs

    Every verify response after the auth-header shape check carries an{" "} diff --git a/packages/kit/src/pages/landing.tsx b/packages/kit/src/pages/landing.tsx index 3da488c1e..f5a51eecf 100644 --- a/packages/kit/src/pages/landing.tsx +++ b/packages/kit/src/pages/landing.tsx @@ -81,7 +81,9 @@ export default function LandingPage() {

    - {"Validation and analytics are free for every developer."} + { + "Hosted validation and analytics are free under fair-use limits." + }

    - {"No credit card for validation or analytics."} + { + "No credit card. Shared, community-funded infrastructure with best-effort availability." + }

    @@ -242,44 +246,92 @@ export default function LandingPage() { - {/* Sponsorship Section */} + {/* Shared capacity and sponsorship section */}

    - {"Sponsor OpenIAP"} + {"Free, shared, and sustained by the ecosystem"}

    -

    +

    { - "Core IAPKit validation and analytics are free for every developer. If your team depends on them, help sustain the project — every contribution keeps the foundation available for thousands of indie developers." + "Hosted IAPKit is an open-source community service that every developer can share. It has fair-use rate limits, does not include an SLA, and is not unlimited infrastructure." }

    -

    - { - "Advanced AI-assisted workflows may be handled separately later, because model token costs are real infrastructure costs." - } -

    -
    + +
    +
    +

    + {"Shared for the community"} +

    +

    + { + "Use the hosted service without a billing meter, cache reads responsibly, and honor 429 or 503 retry guidance so capacity remains available to everyone." + } +

    +
    +
    +

    + {"Planning significant traffic?"} +

    +

    + { + "Contact us before launch. If your organization expects to consume a meaningful share of the hosted capacity, we ask it to help fund server expansion, monitoring, and security for the ecosystem." + } +

    +
    +
    +

    + {"Need dedicated capacity?"} +

    +

    + { + "Self-host the MIT-licensed server for predictable capacity and operational control. Sponsorship supports the shared service; it does not automatically include dedicated resources or an SLA." + } +

    +
    +
    + +
    - {"Become a sponsor"} + {"Sponsor on GitHub"} - +
    +

    + { + "For capacity planning or a separate written service agreement, contact " + } + + hyo@hyo.dev + + {" before your production launch."} +