@siax/mobile-billing (0.1.1)
Installation
@siax:registry=https://git.cloud.siax.io/api/packages/sax3l/npm/npm install @siax/mobile-billing@0.1.1"@siax/mobile-billing": "0.1.1"About this package
@siax/mobile-billing
Entitlements + ytan för StoreKit 2 / Play Billing. Appen frågar aldrig butiken om rättigheter (IAP-06 🔴).
Status: klienten är implementerad och testad (43 tester). Tjänsten den
talar med finns inte än — services/entitlements/ (prompt M8) är blockerad av
PAY0. Klienten är byggd komplett mot kontraktet med fetch injicerbar och körs
mot en stub tills tjänsten reses.
De två reglerna som bär paketet
IAP-06 (🔴) Rättigheter kommer från GET /me/entitlements. Källan — Apple,
Google, Revolut, manuell tilldelning, kampanj, avtal — är osynlig för appen. Det
är därför en avtalskund och en Apple-prenumerant ser exakt samma app utan en rad
specialkod. Klientens modell har inget source-fält, och parsern släpper
igenom endast de fält kontraktet definierar: ett source eller receipt i
serverns svar når aldrig appens datamodell (verifierat i test).
IAP-16 (🔴) Ingen entitlement-logik i appen. Appen renderar rättigheter, den beslutar dem inte. Det finns därför ingen funktion i paketet som härleder en rättighet ur ett kvitto, ett datum eller ett butikssvar — och ett test misslyckas om någon exporterar en.
Konkret vad det betyder:
isGranted()läser servernsgranted. Ett passeratexpiresAtupphäver inte ettgranted: true(servern kan ha gett respit eller ha ett avtal), och ett framtidaexpiresAtger inte rättighet när servern sagt nej.- En rättighet utan ett uttryckligt booleskt
grantedkastas vid parsning. Välvilligt gissande är beslutsfattande i förklädnad. - En butiksadapter som returnerar
granted,isPro,entitlements,unlocked,expiresAt… avvisas medstore_overreach— innan något skickas till servern. Den enda anledningen att lägga ettisPro-fält i ett butikssvar är att någon tänker rendera på det.
Vad som faktiskt är byggt
| Fil | Innehåll |
|---|---|
src/client.ts |
BillingClient — getEntitlements(), can(), purchase(), products(), signOut() |
src/cache.ts |
EntitlementCache med maxålder, klockskyddskontroll, subjekt- och miljöbindning |
src/entitlements.ts |
Uppslagning och presentation — isGranted, describeState, sandboxBanner |
src/store.ts |
StoreAdapter-kontraktet + IAP-16-spärrarna |
src/errors.ts |
Problem Details + BillingError med maskinläsbara koder |
Cache: offline utan evig giltighet
Ordningen är färsk cache → servern → gammal-men-giltig cache. Den sista grenen gör appen användbar i tunnelbanan; maxåldern gör att den inte blir en permanent gratisbiljett. Standard: färsk i 5 minuter, användbar i 72 timmar. Ytor med hög kostnad (AI-krediter) bör sätta betydligt kortare.
Cachen avvisar dessutom:
- poster som hör till en annan användare (
subjectjämförs mot inloggad), - sandbox-poster i produktion och tvärtom (IAP-14),
- poster hämtade "i framtiden" — dvs. enhetens klocka ställd bakåt, det enklaste sättet att försöka förlänga en cache,
- trasig JSON och gammal schemaversion.
Inga kvitton, transaktions-id eller butiksdata sparas — endast serverns färdiga
beslut. signOut() måste anropas vid utloggning, annars ärver nästa
användare rättigheterna.
När rättigheten inte kan verifieras är can() fail-closed. Att fail-open här
vore att ge bort produkten varje gång tjänsten hostar.
Köpordningen är säkerhetskritisk
- butiken tar betalt och lämnar en ogenomskinlig referens,
- servern validerar den mot App Store Server API / Google Play Developer API (IAP-03) — klientsidig kvittovalidering räknas som ingen validering,
- först därefter kvitteras transaktionen mot butiken,
- rättigheterna hämtas om från servern, aldrig ur köpsvaret.
Kvitteras transaktionen före steg 2 är den förlorad och användaren har betalat
för något hen inte fick. Ett 202 från servern ger pending — transaktionen
behålls så att butiken fortsätter påminna (IAP-05, idempotent omförsök). Ett
422 ger validation_rejected och ingen kvittens.
Sandbox kan inte förväxlas med produktion (IAP-14)
Miljön kommer från butiksadaptern, inte från en byggflagga — en
TestFlight-build är inte __DEV__ men handlar mot sandbox. Klienten vägrar
konstrueras om adaptern och konfigurationen är oense; ett sandbox-svar avvisas
hårt i en produktionsapp; en sandbox-cache läses inte i produktionsläge; och
sandboxBanner() ger den etikett som måste synas i UI:t.
Vad som INTE är gjort
- Entitlement-tjänsten finns inte.
GET /me/entitlementsochPOST /me/purchasesär kontrakt, inte körande endpoints. Ingenting här har talat med en riktig server. Prompt M8 är blockerad av PAY0 (K2:s E11 / K3:s B11). - Ingen StoreKit- eller Play Billing-kod. Paketet definierar
StoreAdapteroch innehåller ingen native-implementation. Apprepot kopplar inexpo-in-app-purchases,react-native-iapeller en egen modul. Avsiktligt: SDK:t måste kunna byggas och testas i Node, och butiksbiblioteken byts oftare än kontraktet. - Ingen server-side kvittovalidering. Den hör hemma i PAY0 (M8), inte här. Det här paketet kan bara vägra att göra den på klienten.
- Ingen B00K-bokföring, ingen CL0UD-capability-koppling. IAP-07 och IAP-08 uppfylls av tjänsten, inte av klienten.
- Ingen återställning av köp ("restore purchases"). Inte implementerad.
- Ingen prenumerationshantering (uppgradering, byte av plan, uppsägning) — de flödena går via butikens egna skärmar och är inte inkapslade här.
- Cachen är inte signerad. En rotad enhet kan skriva om den lokala posten. Skyddet mot det är serverkontrollen vid nästa hämtning och maxåldern — inte kryptografi på klienten.
- Ingen typkontroll har körts (repot saknar byggberoenden).
Tester
cd packages/mobile-billing && node --test # eller: npm test
43 tester, alla gröna. Kräver Node ≥ 22.18, inga beroenden.