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.
| Actor | Carries | What it may do |
|---|---|---|
user | userId | Act 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. |
system | service | A trusted internal service. The privileged automated flows: topUp, refund, clawback, promo and entitlement grants and revokes, settlePayout. Full access today. |
operator | operatorId | A 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:
- Privileged operations are closed to
useractors. The setRESTRICTED_TO_PRIVILEGEDlists every kind ausermay never run. - A
usermay 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 operation | Why a user is barred |
|---|---|
topUp | Mints spendable credits; only the trusted payment path may issue. |
grantPromo | Issues marketing credit a user could otherwise grant themself. |
grantEntitlement / revokeEntitlement | Names an arbitrary account the caller need not own; no debit for the ownership check to catch. |
refund | Debits a seller’s earned balance. Self-serve refund is a fraud vector. |
clawback | Reclaims credits from an account the actor need not own. |
reversePayout | Force-fails a payout in flight; an emergency action run by hand. |
settlePayout | Disburses real USD out of trust; a user must never settle their own payout. |
adjust / reverse | Manual 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
operatorreturns immediately, full access, postings fully audited. - A
systemactor returns immediately, full access. - A
useris checked againstRESTRICTED_TO_PRIVILEGED; a match throwsUNAUTHORIZED. Otherwise, for each account indebitedUserAccounts(operation), the gate verifiesownedBy(account, userId). An account belongs to a user when its id is prefixeduserId:(seesource).
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, asystemservice, or a humanoperator. - A
userprincipal may act only on their own accounts; the gate answers before any money moves. - A violation is a thrown
AUTH.UNAUTHORIZEDfault, 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,
};
}