/** * The entitlement decision: a pure function, no crypto and no network. Given a * decoded StoreKit 2 transaction and the gate config, decide whether the caller * is entitled. Keeping it pure makes it testable in isolation. * * EXAMPLE CODE. Placeholders only. */ import type { JWSTransaction } from "./transaction.ts"; export interface GateConfig { allowedBundleIds: Set; allowedProductIds: Set | null; // null = any product now: number; // ms since epoch } export type GateResult = | { ok: true } | { ok: false; status: 401 | 403; reason: string }; /** Parse a comma-separated env var into a trimmed, non-empty Set. */ export function parseList(value: string | undefined): Set { return new Set( (value ?? "") .split(",") .map((s) => s.trim()) .filter((s) => s.length > 0), ); } export function evaluateEntitlement( txn: JWSTransaction, cfg: GateConfig, ): GateResult { if (!txn.bundleId || !cfg.allowedBundleIds.has(txn.bundleId)) { return { ok: false, status: 403, reason: "bundle_id_not_allowed" }; } if ( cfg.allowedProductIds && (!txn.productId || !cfg.allowedProductIds.has(txn.productId)) ) { return { ok: false, status: 403, reason: "product_id_not_allowed" }; } // A sandbox transaction is signed by the same chain as production and costs // nothing to mint, so it must never clear the gate. if (txn.environment !== "Production") { return { ok: false, status: 403, reason: "environment_not_production" }; } if (txn.revocationDate != null) { return { ok: false, status: 403, reason: "entitlement_revoked" }; } // Trial and paid are the same check: a subscription is valid while its // expiresDate is in the future. Non-subscription products omit expiresDate. if (txn.expiresDate != null && txn.expiresDate <= cfg.now) { return { ok: false, status: 403, reason: "entitlement_expired" }; } return { ok: true }; }