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.
Source src/contract.ts#L49-L55 · Principalsrc/economy.ts#L439-L460 · authorize
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 — the principal making the request — and 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({
kind: 'topUp',
idempotencyKey: '550e8400-e29b-41d4-a716-446655440001',
actor: { kind: 'system', service: 'payments' }, // a user actor here is refused
userId: buyer,
amount: decodeAmount('50.00', 'CREDIT'),
source: 'card',
});
Why it exists
Money operations differ sharply in who should be allowed to run them. 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 requesting it on themself.
Without a gate, that line disappears. An end user could call topUp to credit their own wallet. 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, before validation's shape checks turn into posted ledger entries. Keeping the decision in one function also keeps it auditable: one place says who may do what, instead of a scattered check per handler that can drift.
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 pulls out the accounts the operation will drain and checks that each one belongs to the caller. Paying into a stranger's account is fine; draining one is not.
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
The gate runs early, so a refused request costs nothing past the shape check. authorize(operation) runs near the top of submit, right after the request's shape is validated and before the money transaction opens (see source).
The logic is short:
- 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).