Contents

Actors & authorization

Who can do what: every request names an actor (user, system, or operator) and passes a central authorization gate before any work runs.

A “principal” is security-speak for whoever is behind a request — the specific person, service, or staff member the system holds responsible for it. economy-lab makes every request name its principal first, because you cannot decide what someone may do until you know who they are.

Source src/contract.ts#L68-L75Principalsrc/economy.ts#L792-L795authorize

The idea

Before any operation touches money, the economy has to answer one question: who is asking, and may they do this? Every Operation carries an actor. A single gate, authorize, runs before any other work.

The actor is one of three kinds: a user acting for themself, a trusted system service, or a human operator. The gate decides whether that kind of caller may run that kind of operation. For a user, it also checks whether they own the accounts the operation drains.

If the gate says no, the caller never reaches the ledger:

await economy.submit(
  topUp({
    idempotencyKey: '550e8400-e29b-41d4-a716-446655440001',
    actor: systemActor('payments'), // a user actor here is refused
    userId: buyer,
    amount: decodeAmount('50.00', 'CREDIT'),
    source: 'card',
  }),
);

Why it exists

Minting credits with a topUp, clawing back a chargeback, or hand-posting a correction has to come from the platform, never from an end user.

Without a gate, that line disappears. An end user could call topUp to credit their own wallet, issuing credits with no backing dollar behind them and opening a solvency gap. They could call refund to debit a seller’s earned balance into their own, a self-serve fraud vector.

Authorization closes those paths up front, refusing the caller before anything posts. One function decides who may do what, so the rule stays auditable.

The three actors

Each kind of principal carries its own identifier and its own reach.

ActorCarriesWhat it may do
useruserIdAct on its own accounts: spend, subscribe, requestPayout, cancelSubscription, and reads. It may pay into another account but never debit out of one it does not own.
systemserviceA trusted internal service. The privileged automated flows: topUp, refund, clawback, promo and entitlement grants and revokes, settlePayout. Full access today.
operatoroperatorIdA human running a manual, fully-audited fix: adjust and reverse, plus anything system can do.

The invariant it maintains

A user actor can only move money out of accounts it owns, and can never run a privileged operation. The gate holds two rules to make that true:

  1. Privileged operations are closed to user actors. The set RESTRICTED_TO_PRIVILEGED lists every kind a user may never run.
  2. A user may only debit accounts it owns. The gate checks that every account the operation drains belongs to the caller. Paying into a stranger’s account is fine.

The privileged set is, per source:

Restricted operationWhy a user is barred
topUpMints spendable credits; only the trusted payment path may issue.
grantPromoIssues marketing credit a user could otherwise grant themself.
grantEntitlement / revokeEntitlementNames an arbitrary account the caller need not own; no debit for the ownership check to catch.
refundDebits a seller’s earned balance. Self-serve refund is a fraud vector.
clawbackReclaims credits from an account the actor need not own.
reversePayoutForce-fails a payout in flight; an emergency action run by hand.
settlePayoutDisburses real USD out of trust; a user must never settle their own payout.
adjust / reverseManual operator corrections, each requiring a written reason in the audit trail.

How it’s enforced

authorize(operation) runs near the top of submit, right after the request’s shape is validated and before the money transaction opens, so a refused request never opens a transaction (see source).

The gate’s logic:

  • An operator returns immediately, full access, postings fully audited.
  • A system actor returns immediately, full access.
  • A user is checked against RESTRICTED_TO_PRIVILEGED; a match throws UNAUTHORIZED. Otherwise, for each account in debitedUserAccounts(operation), the gate verifies ownedBy(account, userId). An account belongs to a user when its id is prefixed userId: (see source).

debitedUserAccounts is deliberately narrow: it returns only the drained user accounts an operation touches, not every account it locks. For a spend those are the buyer’s promo and spendable; for a subscribe, the same; for a requestPayout, the user’s earned.

Accounts being paid into, and platform accounts, are left out. Only a drained account has to belong to the caller (see source).

Ownership the gate can’t see

Some operations name a resource that has no drained account in the request itself: a subscription to cancel, a payout saga to act on. The request body carries the subscription id or saga id, not the underlying account, so debitedUserAccounts can’t check it.

Those operations carry their ownership check inside the handler instead, once the gate has confirmed the actor kind.

What relies on it

Authorization sits in front of the whole submit pipeline, so every operation passes through it. Two groups depend on it directly.

Privileged operations trust the gate to keep user callers out, so their handlers assume a system or operator principal. See settlePayout and the operator-only adjust.

The maintenance pause tells actors apart by kind. A user’s discretionary write is declined with ECONOMY_PAUSED while the economy is paused. A system settlement webhook and an operator fix keep flowing, so external money can still settle (see source).

Recap

  • Every request names its principal: a user, a system service, or a human operator.
  • A user principal may act only on their own accounts; the gate answers before any money moves.
  • A violation is a thrown AUTH.UNAUTHORIZED fault, not a rejection — it is a caller bug, not a business “no”.

Now put the claims to work.

Challenge: find the violation

A small storefront routine, two submits. One of them runs as the wrong principal. Run it first — the fault names itself — then click into the code and fix the actor so the whole routine commits.

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

import type { Economy } from '@pwngh/economy-lab';
import type { SnippetReport } from './context.ts';

// A small storefront routine: fund the buyer, then place their order.
export async function run(economy: Economy): Promise<SnippetReport> {
  const buyerId = `usr_shop_${crypto.randomUUID().slice(0, 6)}`;

  await economy.submit(
    topUp({
      idempotencyKey: `idem_${buyerId}`,
      actor: systemActor('billing'), // the platform's billing service tops up — fine
      userId: buyerId,
      amount: credits(150),
      source: 'card',
    }),
  );

  const order = await economy.submit(
    spend({
      idempotencyKey: `ord_${buyerId}`,
      actor: userActor('usr_nova'), // the seller submits the buyer's spend
      orderId: `ord_${buyerId}`,
      buyerId,
      sku: 'Gallery Print',
      price: credits(100),
      recipients: [{ sellerId: 'usr_nova', shareBps: 10_000 }],
    }),
  );

  return {
    lines: [
      `order: ${order.status}${order.status === 'committed' ? ` → ${order.transaction.id}` : ''}`,
      'both submits ran as principals the engine accepts',
    ],
  };
}
Hint

Read each submit's actor line against the account being drained. Who is allowed to spend from that wallet?

Solution

The spend ran as userActor('usr_nova') — the seller — against the buyer's wallet, and a user principal may only act on their own accounts, so the gate threw AUTH.UNAUTHORIZED before any money moved. The buyer submits their own order: actor: userActor(buyerId). One line.

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

import type { Economy } from '@pwngh/economy-lab';
import type { SnippetReport } from './context.ts';

// The violation was the spend's actor: a `user` principal may only act on their own
// accounts, and the seller was submitting the buyer's spend. The buyer submits their
// own order — that's the fix, one line.
export async function run(economy: Economy): Promise<SnippetReport> {
  const buyerId = `usr_shop_${crypto.randomUUID().slice(0, 6)}`;

  await economy.submit(
    topUp({
      idempotencyKey: `idem_${buyerId}`,
      actor: systemActor('billing'), // the platform's billing service tops up — fine
      userId: buyerId,
      amount: credits(150),
      source: 'card',
    }),
  );

  const order = await economy.submit(
    spend({
      idempotencyKey: `ord_${buyerId}`,
      actor: userActor(buyerId), // the buyer acts on their own wallet
      orderId: `ord_${buyerId}`,
      buyerId,
      sku: 'Gallery Print',
      price: credits(100),
      recipients: [{ sellerId: 'usr_nova', shareBps: 10_000 }],
    }),
  );

  return {
    lines: [
      `order: ${order.status}${order.status === 'committed' ? ` → ${order.transaction.id}` : ''}`,
      'both submits ran as principals the engine accepts',
    ],
    txnId: order.status === 'committed' ? order.transaction.id : undefined,
  };
}

See also