Contents

An entitlement-gated feature

Sell a SKU once, gate access on read.entitled, and revoke it when the money comes back.

Source src/operations/entitlements.ts#L32grantEntitlementsrc/operations/spend.ts#L80spend

You are selling access — a world, a feature, an upload slot — not a stack of consumables. The recipe is to let the sale itself record ownership and to ask one reader everywhere access is decided.

A spend grants its sku to the buyer in the same transaction that moves the money, so there is no separate fulfillment step to lose. From then on, read.entitled answers the gate:

import { createEconomy, credits, spend, systemActor, topUp, userActor } from '@pwngh/economy-lab';

import type { SnippetReport } from './context.ts';

// Ownership is a record, not a balance: the spend that sells a SKU grants it in the same
// transaction, and `read.entitled` is the gate the rest of your service asks before it unlocks
// anything. No separate fulfillment step to lose.
export async function run(): Promise<SnippetReport> {
  const economy = await createEconomy();
  await economy.submit(
    topUp({
      idempotencyKey: 'idem_fund',
      actor: systemActor('payments'),
      userId: 'usr_g',
      amount: credits(100),
      source: 'card',
    }),
  );

  const before = await economy.read.entitled('usr_g', 'wrld_cape');
  await economy.submit(
    spend({
      idempotencyKey: 'idem_buy',
      actor: userActor('usr_g'),
      orderId: 'ord_g1',
      buyerId: 'usr_g',
      sku: 'wrld_cape',
      price: credits(40),
      recipients: [{ sellerId: 'usr_s', shareBps: 10_000 }],
    }),
  );
  const after = await economy.read.entitled('usr_g', 'wrld_cape');
  await economy.close();

  return {
    lines: [
      `entitled('usr_g', 'wrld_cape') before the purchase: ${before}`,
      'spend committed — the same transaction granted the SKU',
      `entitled('usr_g', 'wrld_cape') after: ${after}`,
    ],
    consolePath: '/market',
  };
}

The gate in your service is one guard clause:

async function equipCape(userId: string) {
  if (!(await economy.read.entitled(userId, 'wrld_cape'))) {
    return refuse('This feature needs the Cape.');
  }
  equip(userId, 'wrld_cape');
}

Two edges complete the recipe:

  • Comped access is grantEntitlement: the same ownership record without a sale, for support gestures and creator comps.
  • Money coming back takes the access with it. A refund or a chargeback-driven revoke calls revokeEntitlement, and the same read.entitled gate closes everywhere at once.

See also