← Provider replacement walkthrough

Commerce Protocol Example · provider.mjs

Source snapshot · 2026-09-09

SHA-256 682b2c24797d5007373c1951641b2f6f156c933e654b2ad1865219be64b15d51
1import { Database } from "bun:sqlite";2import { createHash, randomUUID } from "node:crypto";3import {4  providerCapabilitiesSchema,5  COMMERCE_EVENT_VERSION,6  HTTP_BINDING,7} from "openiap-commerce-protocol";8import { protocolError, validate } from "./contract.mjs";9import { createErasureLedger } from "./erasure.mjs";10 11export const FIXTURE = Object.freeze({12  store: "fixture",13  evidence: "local-purchase-alice",14  userId: "demo_alice",15  productId: "premium.monthly",16  startsAt: Date.UTC(2026, 8, 7, 9),17  expiresAt: Date.UTC(2026, 9, 7, 9),18});19export const CREDENTIALS = Object.freeze({20  verification: "Bearer local-demo-verification",21  server: "Bearer local-demo-server",22});23const fingerprint = (value) => createHash("sha256").update(value).digest("hex");24 25export function isEntitled(state, expiresAt, now) {26  return (27    (state === "Active" || state === "InGracePeriod") &&28    (expiresAt == null || now < expiresAt)29  );30}31 32// This adapter recognizes one fictional purchase; it never contacts a store.33function fixtureEvidence(input) {34  switch (input.store) {35    case "apple":36      return input.apple?.jws;37    case "google":38      return input.google?.purchaseToken;39    case "amazon":40      return (41        input.amazon &&42        JSON.stringify([43          input.amazon.userId,44          input.amazon.receiptId,45          input.amazon.sandbox === true,46        ])47      );48    case "horizon":49      return (50        input.horizon &&51        JSON.stringify([input.horizon.userId, input.horizon.sku])52      );53    default:54      return input.evidence;55  }56}57 58function verifyFixture(input, fixture) {59  if (input.store !== fixture.store) return { error: "UNSUPPORTED_STORE" };60  const evidence = fixtureEvidence(input);61  if (typeof evidence !== "string") return { error: "INVALID_REQUEST" };62  if (evidence === "local-upstream-outage") {63    return { error: "VERIFICATION_FAILED" };64  }65  const current = fixture.currentVerdict?.();66  if (current === "outage") return { error: "VERIFICATION_FAILED" };67  return { accepted: evidence === fixture.evidence && current !== false };68}69 70export function createProvider(path, now, fixture = FIXTURE) {71  const db = new Database(path, { create: true });72  db.exec(`73    PRAGMA journal_mode = WAL;74    CREATE TABLE IF NOT EXISTS purchases (75      fingerprint TEXT PRIMARY KEY, user_id TEXT, product_id TEXT NOT NULL,76      state TEXT NOT NULL, expires_at INTEGER NOT NULL, will_renew INTEGER NOT NULL,77      observed_at INTEGER NOT NULL, entitlement_granted INTEGER NOT NULL DEFAULT 078    );79    CREATE TABLE IF NOT EXISTS observations (id TEXT PRIMARY KEY);80    CREATE TABLE IF NOT EXISTS outbox (81      event_id TEXT PRIMARY KEY, delivery_id TEXT NOT NULL, body TEXT NOT NULL,82      attempts INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending',83      next_at INTEGER NOT NULL DEFAULT 084    );85  `);86  const erasures = createErasureLedger(db);87  if (88    !db89      .query("PRAGMA table_info(purchases)")90      .all()91      .some((column) => column.name === "erased")92  )93    db.exec(94      "ALTER TABLE purchases ADD COLUMN erased INTEGER NOT NULL DEFAULT 0",95    );96 97  function snapshot(row) {98    return {99      productId: row.product_id,100      state: row.state,101      active: isEntitled(row.state, row.expires_at, now()),102      store: fixture.store,103      expiresAt: row.expires_at,104      willRenew: Boolean(row.will_renew),105    };106  }107 108  function rowsFor(userId) {109    return db.query("SELECT * FROM purchases WHERE user_id = ?").all(userId);110  }111 112  function entitlements(userId) {113    if (fixture.pointInTime) {114      const current = fixture.currentVerdict?.();115      if (current === "outage") return { error: "VERIFICATION_FAILED" };116      return {117        userId,118        productIds:119          current === false120            ? []121            : [...new Set(rowsFor(userId).map((row) => row.product_id))],122        subscriptions: [],123      };124    }125    const subscriptions = rowsFor(userId)126      .map(snapshot)127      .filter((row) => row.active);128    return {129      userId,130      productIds: [...new Set(subscriptions.map((row) => row.productId))],131      subscriptions,132    };133  }134 135  const eventTypes = [136    "entitlement.granted",137    "subscription.canceled",138    "subscription.expired",139    "entitlement.revoked",140  ];141  const capabilityNames = Object.keys(142    providerCapabilitiesSchema.$defs.StoreCapabilities.properties,143  );144  const supported = new Set([145    "initialValidation",146    "subscriptions",147    "entitlements",148    "serverNotifications",149    "expiration",150  ]);151  const capabilities = {152    specVersion: HTTP_BINDING.protocolVersion,153    implementation: {154      name: "Commerce Protocol Example — fictional fixture store",155    },156    eventTypes,157    stores: {158      [fixture.store]: Object.fromEntries(159        capabilityNames.map((key) => [160          key,161          {162            provider:163              supported.has(key) &&164              (!fixture.pointInTime ||165                ["initialValidation", "entitlements"].includes(key)),166            implementation:167              supported.has(key) &&168              (!fixture.pointInTime ||169                ["initialValidation", "entitlements"].includes(key)),170            notes:171              "Local fixture demonstration only; no real store integration or profile conformance claim.",172          },173        ]),174      ),175    },176  };177 178  function enqueue(eventType, row, occurredAt, sourceStoreEventId) {179    const body = {180      eventId: randomUUID(),181      eventType,182      eventVersion: COMMERCE_EVENT_VERSION,183      occurredAt,184      processedAt: now(),185      store: fixture.store,186      environment: "local-fixture",187      projectId: "commerce_example",188      productId: row.product_id,189      ...(row.user_id ? { userId: row.user_id } : {}),190      subscription: snapshot(row),191      ...(sourceStoreEventId ? { sourceStoreEventId } : {}),192    };193    if (!validate("#/$defs/CommerceEvent", body))194      throw new Error("Invalid event");195    db.query(196      "INSERT INTO outbox (event_id, delivery_id, body) VALUES (?, ?, ?)",197    ).run(body.eventId, randomUUID(), JSON.stringify(body));198  }199 200  const handlers = {201    providerCapabilities: () => capabilities,202    verifyPurchase(input) {203      const verdict = verifyFixture(input, fixture);204      if (verdict.error) return verdict;205      if (verdict.accepted) {206        db.query(207          `INSERT OR IGNORE INTO purchases (fingerprint, user_id, product_id, state, expires_at, will_renew, observed_at) VALUES (?, NULL, ?, 'Active', ?, 1, ?)`,208        ).run(209          fingerprint(fixtureEvidence(input)),210          fixture.productId,211          fixture.expiresAt,212          fixture.startsAt,213        );214      }215      const expired = !fixture.pointInTime && now() >= fixture.expiresAt;216      return {217        store: fixture.store,218        isValid: verdict.accepted && !expired,219        state: !verdict.accepted220          ? "INAUTHENTIC"221          : expired222            ? "EXPIRED"223            : "ENTITLED",224        ...(verdict.accepted ? { productId: fixture.productId } : {}),225        environment: "local-fixture",226      };227    },228    bindPurchase(input) {229      if (input.store !== fixture.store) return { error: "UNSUPPORTED_STORE" };230      if (typeof fixtureEvidence(input) !== "string")231        return { error: "INVALID_REQUEST" };232      return db.transaction(() => {233        if (erasures.has(input.userId)) return { bound: false };234        const key = fingerprint(fixtureEvidence(input));235        const updated = db236          .query(237            "UPDATE purchases SET user_id = ? WHERE fingerprint = ? AND user_id IS NULL AND erased = 0",238          )239          .run(input.userId, key);240        const row = db241          .query("SELECT * FROM purchases WHERE fingerprint = ?")242          .get(key);243        if (244          updated.changes &&245          !fixture.pointInTime &&246          isEntitled(row.state, row.expires_at, now())247        ) {248          enqueue("entitlement.granted", row, row.observed_at);249          db.query(250            "UPDATE purchases SET entitlement_granted = 1 WHERE fingerprint = ?",251          ).run(key);252        }253        return { bound: row?.user_id === input.userId };254      })();255    },256    entitlements: (input) => entitlements(input.userId),257    eraseUser(input) {258      return db.transaction(() => {259        const jobId = erasures.remember(input.userId);260        db.query(261          "UPDATE purchases SET user_id = NULL, erased = 1, entitlement_granted = 0 WHERE user_id = ?",262        ).run(input.userId);263        // A claimed delivery may already be in flight; the receiver erases its own copy.264        db.query(265          "DELETE FROM outbox WHERE json_extract(body, '$.userId') = ?",266        ).run(input.userId);267        return { accepted: true, jobId, status: "completed" };268      })();269    },270    subscriptionStatus(input) {271      if (fixture.pointInTime) return { active: false };272      const snapshots = rowsFor(input.userId).map(snapshot);273      const subscription = snapshots.find((row) => row.active) ?? snapshots[0];274      return {275        active: snapshots.some((row) => row.active),276        ...(subscription ? { subscription } : {}),277      };278    },279  };280 281  async function fetch(request) {282    const url = new URL(request.url);283    const spec = HTTP_BINDING.operations.find(284      (entry) => entry.path === url.pathname && entry.method === request.method,285    );286    if (!spec) return protocolError("NOT_FOUND");287    const auth = request.headers.get("authorization");288    if (spec.auth !== "none") {289      if (!Object.values(CREDENTIALS).includes(auth))290        return protocolError("UNAUTHORIZED");291      if (spec.auth === "server" && auth !== CREDENTIALS.server)292        return protocolError("FORBIDDEN");293    }294    if (!handlers[spec.name]) return protocolError("UNSUPPORTED_PROFILE");295    let input = null;296    if (spec.input) {297      try {298        input =299          request.method === "GET"300            ? Object.fromEntries(url.searchParams)301            : await request.json();302      } catch {303        return protocolError("INVALID_REQUEST");304      }305      if (!validate(spec.input, input)) return protocolError("INVALID_REQUEST");306    }307    try {308      const result = handlers[spec.name](input);309      if (result.error) return protocolError(result.error);310      if (!validate(spec.result, result))311        throw new Error("Invalid protocol response");312      return Response.json(result, { status: spec.successStatus });313    } catch {314      return protocolError("INTERNAL_ERROR");315    }316  }317 318  function observe({ id, kind, occurredAt }) {319    if (320      !["cancel", "expire"].includes(kind) ||321      !Number.isSafeInteger(occurredAt) ||322      occurredAt > now()323    ) {324      throw new Error("Invalid fixture observation");325    }326    return db.transaction(() => {327      if (db.query("SELECT id FROM observations WHERE id = ?").get(id))328        return false;329      const row = db330        .query("SELECT * FROM purchases WHERE fingerprint = ?")331        .get(fingerprint(fixture.evidence));332      if (!row) throw new Error("Verify the fixture purchase first");333      if (kind === "expire" && occurredAt < row.expires_at) {334        throw new Error("Premature expiry requires store reconciliation");335      }336      db.query("INSERT INTO observations VALUES (?)").run(id);337      if (occurredAt < row.observed_at) return false;338      if (339        (kind === "cancel" && (!row.will_renew || row.state !== "Active")) ||340        (kind === "expire" && row.state === "Expired")341      )342        return false;343      const next = {344        ...row,345        will_renew: 0,346        state: kind === "expire" ? "Expired" : row.state,347      };348      db.query(349        "UPDATE purchases SET state = ?, will_renew = 0, observed_at = ? WHERE fingerprint = ?",350      ).run(next.state, occurredAt, row.fingerprint);351      const types = [352        kind === "cancel" ? "subscription.canceled" : "subscription.expired",353      ];354      if (355        row.entitlement_granted &&356        !isEntitled(next.state, next.expires_at, now())357      ) {358        types.push("entitlement.revoked");359        db.query(360          "UPDATE purchases SET entitlement_granted = 0 WHERE fingerprint = ?",361        ).run(row.fingerprint);362      }363      for (const eventType of types) enqueue(eventType, next, occurredAt, id);364      return true;365    })();366  }367 368  function inspect() {369    return {370      purchases: db371        .query(372          "SELECT user_id AS userId, product_id AS productId, state, will_renew AS willRenew FROM purchases",373        )374        .all(),375      access: entitlements(fixture.userId),376      deliveries: db377        .query(378          "SELECT event_id AS eventId, delivery_id AS deliveryId, attempts, status, body FROM outbox ORDER BY rowid",379        )380        .all()381        .map(({ body, ...row }) => ({382          ...row,383          eventType: JSON.parse(body).eventType,384        })),385    };386  }387 388  return { db, fetch, observe, inspect, close: () => db.close() };389}390