@pithy-sh/ledger 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +49 -0
- package/pithy.manifest.json +30 -0
- package/src/admin/read.ts +150 -0
- package/src/audit/actions.ts +30 -0
- package/src/capability.ts +97 -0
- package/src/cloudflare-test.d.ts +12 -0
- package/src/config/config.ts +73 -0
- package/src/data/account.ts +43 -0
- package/src/data/hold.ts +39 -0
- package/src/data/tables.ts +43 -0
- package/src/data/transaction.ts +48 -0
- package/src/error/errors.ts +119 -0
- package/src/http/guards.ts +79 -0
- package/src/http/responses.ts +108 -0
- package/src/http/routes.ts +336 -0
- package/src/http/schemas.ts +107 -0
- package/src/http/scopes.ts +95 -0
- package/src/http/view.ts +74 -0
- package/src/index.ts +23 -0
- package/src/ledger.ts +324 -0
- package/src/migrations/0001_accounts.ts +75 -0
- package/src/seeds/example.ts +65 -0
- package/src/version.generated.ts +16 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { zValidator } from "@hono/zod-validator";
|
|
6
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
7
|
+
import type { ControlPlaneContext } from "@pithy-sh/core/src/controlPlane/context";
|
|
8
|
+
import { requireControlPlane } from "@pithy-sh/core/src/controlPlane/http/guard";
|
|
9
|
+
import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
|
|
10
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
11
|
+
import { validationHook } from "@pithy-sh/core/src/http/validation";
|
|
12
|
+
import type { VerificationStrategy } from "@pithy-sh/core/src/http/verification";
|
|
13
|
+
import type { Context, Hono } from "hono";
|
|
14
|
+
import { listAccounts, listTransactions, readAccounts } from "../admin/read";
|
|
15
|
+
import { type LedgerAuditAction, LedgerAuditActions } from "../audit/actions";
|
|
16
|
+
import { type LedgerConfig, resolveCurrency } from "../config/config";
|
|
17
|
+
import { LedgerCurrencyNotFoundError } from "../error/errors";
|
|
18
|
+
import { openLedger } from "../ledger";
|
|
19
|
+
import { requireAdmin, requireAuth } from "./guards";
|
|
20
|
+
import type { LedgerAccountsResponse, LedgerTransactionsResponse, LedgerUserAccountsResponse } from "./responses";
|
|
21
|
+
import {
|
|
22
|
+
AdminAccountParam,
|
|
23
|
+
AdminAccountsQuery,
|
|
24
|
+
AdminTransactionsQuery,
|
|
25
|
+
AdminUserParam,
|
|
26
|
+
AdminWrite,
|
|
27
|
+
CurrencyParam,
|
|
28
|
+
} from "./schemas";
|
|
29
|
+
import { LEDGER_ACCOUNTS_READ_SCOPE, LEDGER_TRANSACTIONS_READ_SCOPE } from "./scopes";
|
|
30
|
+
import { accountView, transactionView } from "./view";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The ledger routes, their declared verification strategies, and what each accepts. A player can always
|
|
34
|
+
* read their *own* balance and history; **moving funds over HTTP is server-authoritative** and requires
|
|
35
|
+
* the admin scope, because a ledger a client could credit itself is not a ledger; and a management
|
|
36
|
+
* client reads across every player through the control-plane seam:
|
|
37
|
+
*
|
|
38
|
+
* GET /ledger/:currency → your balance (bearer | session) param: CurrencyParam
|
|
39
|
+
* GET /ledger/:currency/transactions → your recent ledger entries (bearer | session) param: CurrencyParam
|
|
40
|
+
* POST /ledger/:currency/credit → add funds to a player (bearer | session + admin scope) param: CurrencyParam, json: AdminWrite
|
|
41
|
+
* POST /ledger/:currency/debit → remove funds from a player (bearer | session + admin scope) param: CurrencyParam, json: AdminWrite
|
|
42
|
+
* GET /ledger/admin/accounts → every account, paged (control-plane: ledger:accounts:read) query: AdminAccountsQuery
|
|
43
|
+
* GET /ledger/admin/accounts/:userId → one player's balances (control-plane: ledger:accounts:read) param: AdminUserParam
|
|
44
|
+
* GET /ledger/admin/accounts/:userId/:currency/transactions → one account's entry log (control-plane: ledger:transactions:read) param: AdminAccountParam, query: AdminTransactionsQuery
|
|
45
|
+
*
|
|
46
|
+
* Most balance movement happens in-process (a game model calling {@link openLedger} directly), not over
|
|
47
|
+
* HTTP; these routes are the read surface players need, a trusted-server admin surface, and a read-only
|
|
48
|
+
* management surface. The user id on a player route comes from the core `AuthContext` seam, never a
|
|
49
|
+
* request body — a player's read is always scoped to the caller.
|
|
50
|
+
*
|
|
51
|
+
* ## The management surface is read-only, deliberately
|
|
52
|
+
*
|
|
53
|
+
* There is no `POST /admin/adjust`. Writing to a balance ledger from an admin console needs everything
|
|
54
|
+
* every other movement gets — an idempotency key so a double-click does not pay twice, a recorded
|
|
55
|
+
* reason, a reversal path — and a console route with none of those would be the one place the ledger's
|
|
56
|
+
* guarantees do not hold. So the seam reads and nothing else: balances, and the entries that explain
|
|
57
|
+
* them.
|
|
58
|
+
*
|
|
59
|
+
* ## Why the management routes sit under `admin/`
|
|
60
|
+
*
|
|
61
|
+
* `${base}/:currency` claims the entire one-segment space beneath the mount point, so a management route
|
|
62
|
+
* at `${base}/accounts` would be ambiguous with a currency called `accounts` and would sit behind
|
|
63
|
+
* whichever of the two Hono matched first — a route's gate decided by registration order. The extra
|
|
64
|
+
* static segment makes the two sets disjoint by construction: `${base}/admin/accounts` cannot collide
|
|
65
|
+
* with `${base}/:currency` (two segments against three) nor with `${base}/:currency/transactions` (the
|
|
66
|
+
* last segment differs), and the deeper ones are longer than anything the player surface mounts.
|
|
67
|
+
*
|
|
68
|
+
* ## `requireAuth()` is never on a management route
|
|
69
|
+
*
|
|
70
|
+
* The seam deliberately leaves `c.var.auth` null, so an auth gate on a control-plane route would deny
|
|
71
|
+
* every legitimate management call permanently and no credential could fix it. `requireControlPlane`
|
|
72
|
+
* **replaces** `requireAuth()` and `requireAdmin()` on those lines; it does not stack with them. See
|
|
73
|
+
* `guards.ts` for the whole argument.
|
|
74
|
+
*
|
|
75
|
+
* Validators sit **after** the guards on every route line: an unauthenticated, unscoped, or unverified
|
|
76
|
+
* caller is turned away before its payload is read, so a bad request can never downgrade a 401/403 to a
|
|
77
|
+
* 400 and tell a caller with no credential which requests were well-formed. They sit **before** the
|
|
78
|
+
* handler, which is why a malformed request on an *unconfigured* currency answers 400 rather than 404 —
|
|
79
|
+
* the request is rejected as unparseable before the currency is ever resolved.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Where the ledger mounts when an adopter names nothing.
|
|
84
|
+
*
|
|
85
|
+
* Exported because two places must agree on it: the router below, and `ledgerAdminRoutes` in
|
|
86
|
+
* `capability.ts`. A default living only in the registrar would let the manifest advertise `/ledger/...`
|
|
87
|
+
* while the routes mounted somewhere else, and a management client composing its calls from the
|
|
88
|
+
* manifest would 404 with nothing to diagnose.
|
|
89
|
+
*/
|
|
90
|
+
export const LEDGER_DEFAULT_BASE_PATH = "/ledger";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What every route this capability mounts declares: its path, its verification strategy, and the
|
|
94
|
+
* control-plane scope it checks when it has one.
|
|
95
|
+
*
|
|
96
|
+
* **Exported so a test can assert against the declaration rather than against a middleware count.**
|
|
97
|
+
* Counting middleware proves that *something* runs before the handler; it cannot prove *what*, and a
|
|
98
|
+
* bare `zValidator` satisfies a count. `routeContract.test.ts` checks this list against the routes Hono
|
|
99
|
+
* actually registered in both directions, so a route added without an entry and an entry naming a route
|
|
100
|
+
* nobody mounts both fail.
|
|
101
|
+
*/
|
|
102
|
+
export interface LedgerRouteDeclaration {
|
|
103
|
+
readonly method: "GET" | "POST";
|
|
104
|
+
/** The path relative to the configured `basePath`, e.g. `/admin/accounts`. */
|
|
105
|
+
readonly path: string;
|
|
106
|
+
readonly strategy: VerificationStrategy;
|
|
107
|
+
/** The control-plane scope this route checks, for a `control-plane` route. */
|
|
108
|
+
readonly scope?: ControlPlaneScope;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Every route, and how it is gated. */
|
|
112
|
+
export const LEDGER_ROUTES: readonly LedgerRouteDeclaration[] = [
|
|
113
|
+
{ method: "GET", path: "/:currency", strategy: "bearer" },
|
|
114
|
+
{ method: "GET", path: "/:currency/transactions", strategy: "bearer" },
|
|
115
|
+
{ method: "POST", path: "/:currency/credit", strategy: "bearer" },
|
|
116
|
+
{ method: "POST", path: "/:currency/debit", strategy: "bearer" },
|
|
117
|
+
{ method: "GET", path: "/admin/accounts", strategy: "control-plane", scope: LEDGER_ACCOUNTS_READ_SCOPE },
|
|
118
|
+
{ method: "GET", path: "/admin/accounts/:userId", strategy: "control-plane", scope: LEDGER_ACCOUNTS_READ_SCOPE },
|
|
119
|
+
{
|
|
120
|
+
method: "GET",
|
|
121
|
+
path: "/admin/accounts/:userId/:currency/transactions",
|
|
122
|
+
strategy: "control-plane",
|
|
123
|
+
scope: LEDGER_TRANSACTIONS_READ_SCOPE,
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
export interface LedgerRoutesOptions {
|
|
128
|
+
config: LedgerConfig;
|
|
129
|
+
basePath?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function db(c: Context<PithyHonoEnv>): D1Database {
|
|
133
|
+
const binding = (c.env as Record<string, unknown>).DB as D1Database | undefined;
|
|
134
|
+
if (!binding) {
|
|
135
|
+
throw new InternalError({
|
|
136
|
+
message: "The ledger is not configured.",
|
|
137
|
+
action: "Bind a D1 database named DB in wrangler.jsonc.",
|
|
138
|
+
detail: "Ledger requires a `DB` D1 binding; none was present on env.",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return binding;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Reject a request whose currency is not configured. */
|
|
145
|
+
function currency(config: LedgerConfig, code: string): string {
|
|
146
|
+
if (!resolveCurrency(config, code))
|
|
147
|
+
throw new LedgerCurrencyNotFoundError({ detail: `No currency "${code}" is configured.` });
|
|
148
|
+
return code;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The verified management client behind a control-plane call.
|
|
153
|
+
*
|
|
154
|
+
* `requireControlPlane()` has run on every route that calls this, so `c.var.controlPlane` is populated
|
|
155
|
+
* by the time a handler reads it. The throw is a programming-error guard rather than a runtime path:
|
|
156
|
+
* reaching it would mean a management route was mounted without its gate, which is the one mistake this
|
|
157
|
+
* file is arranged to make impossible.
|
|
158
|
+
*/
|
|
159
|
+
function caller(c: Context<PithyHonoEnv>): ControlPlaneContext {
|
|
160
|
+
const context = c.var.controlPlane;
|
|
161
|
+
if (!context) {
|
|
162
|
+
throw new InternalError({
|
|
163
|
+
message: "The ledger could not identify the management caller.",
|
|
164
|
+
detail: "requireControlPlane() must run before a ledger management handler reads the caller.",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return context;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Record a management read.
|
|
172
|
+
*
|
|
173
|
+
* **Every read, not only writes.** There are no writes on this surface, so an unaudited one would leave
|
|
174
|
+
* the capability's whole management history blank — and a credential quietly paging every player's
|
|
175
|
+
* balance history is exactly the thing that leaves no other trace. Counts and identifiers only: no
|
|
176
|
+
* balance, no `ref`, no memo. Copying the ledger into the audit trail would make a second ledger with
|
|
177
|
+
* weaker access rules than the first.
|
|
178
|
+
*/
|
|
179
|
+
async function record(
|
|
180
|
+
c: Context<PithyHonoEnv>,
|
|
181
|
+
action: LedgerAuditAction,
|
|
182
|
+
resourceId: string | null,
|
|
183
|
+
metadata: Record<string, unknown>,
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
const who = caller(c);
|
|
186
|
+
await c.var.emit({
|
|
187
|
+
action,
|
|
188
|
+
outcome: "success",
|
|
189
|
+
actorType: "control-plane",
|
|
190
|
+
actorId: who.subject,
|
|
191
|
+
resourceType: "ledger_account",
|
|
192
|
+
resourceId,
|
|
193
|
+
requestId: c.req.header("cf-ray"),
|
|
194
|
+
ip: c.req.header("cf-connecting-ip"),
|
|
195
|
+
userAgent: c.req.header("user-agent"),
|
|
196
|
+
metadata: { connectionId: who.connectionId, ...metadata },
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function registerLedgerRoutes(options: LedgerRoutesOptions): (app: Hono<PithyHonoEnv>) => void {
|
|
201
|
+
const base = options.basePath ?? LEDGER_DEFAULT_BASE_PATH;
|
|
202
|
+
const { config } = options;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The caller's own id. `requireAuth()` has already run on every route that calls this, so a null
|
|
206
|
+
* `auth` is a wiring mistake rather than an unauthenticated request — hence `InternalError`, not a
|
|
207
|
+
* 401. Narrowing here rather than asserting non-null keeps the impossible case impossible to ignore.
|
|
208
|
+
*/
|
|
209
|
+
const callerId = (c: Context<PithyHonoEnv>): string => {
|
|
210
|
+
const auth = c.var.auth;
|
|
211
|
+
if (!auth) {
|
|
212
|
+
throw new InternalError({ detail: "requireAuth() must run before a ledger handler reads the caller." });
|
|
213
|
+
}
|
|
214
|
+
return auth.userId;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
return (app) => {
|
|
218
|
+
// The management surface: control-plane, read-only. Its paths are disjoint from the player routes
|
|
219
|
+
// by construction, so registering it first is presentation rather than semantics.
|
|
220
|
+
|
|
221
|
+
app.get(
|
|
222
|
+
`${base}/admin/accounts`,
|
|
223
|
+
requireControlPlane(LEDGER_ACCOUNTS_READ_SCOPE),
|
|
224
|
+
zValidator("query", AdminAccountsQuery, validationHook),
|
|
225
|
+
async (c) => {
|
|
226
|
+
const query = c.req.valid("query");
|
|
227
|
+
// A currency filter is still config-backed resolution: an operator who mistypes one gets the
|
|
228
|
+
// capability's own 404 rather than an empty pane they would read as "nobody holds any".
|
|
229
|
+
const code = query.currency === undefined ? undefined : currency(config, query.currency);
|
|
230
|
+
const page = await listAccounts(db(c), { ...query, currency: code });
|
|
231
|
+
await record(c, LedgerAuditActions.accountsListed, null, {
|
|
232
|
+
currency: code ?? null,
|
|
233
|
+
returned: page.items.length,
|
|
234
|
+
resumed: query.cursor !== undefined,
|
|
235
|
+
});
|
|
236
|
+
return c.json(
|
|
237
|
+
{ accounts: page.items.map(accountView), nextCursor: page.nextCursor } satisfies LedgerAccountsResponse,
|
|
238
|
+
200,
|
|
239
|
+
);
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
app.get(
|
|
244
|
+
`${base}/admin/accounts/:userId`,
|
|
245
|
+
requireControlPlane(LEDGER_ACCOUNTS_READ_SCOPE),
|
|
246
|
+
zValidator("param", AdminUserParam, validationHook),
|
|
247
|
+
async (c) => {
|
|
248
|
+
const { userId } = c.req.valid("param");
|
|
249
|
+
const accounts = await readAccounts(db(c), userId);
|
|
250
|
+
await record(c, LedgerAuditActions.accountRead, userId, { userId, returned: accounts.length });
|
|
251
|
+
// A player with no account is an empty list, not a 404 — the honest answer, since an account is
|
|
252
|
+
// opened by its first credit and its absence is not a missing player. It also keeps this surface
|
|
253
|
+
// from being an existence oracle for user ids.
|
|
254
|
+
return c.json({ userId, accounts: accounts.map(accountView) } satisfies LedgerUserAccountsResponse, 200);
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
app.get(
|
|
259
|
+
`${base}/admin/accounts/:userId/:currency/transactions`,
|
|
260
|
+
requireControlPlane(LEDGER_TRANSACTIONS_READ_SCOPE),
|
|
261
|
+
zValidator("param", AdminAccountParam, validationHook),
|
|
262
|
+
zValidator("query", AdminTransactionsQuery, validationHook),
|
|
263
|
+
async (c) => {
|
|
264
|
+
const { userId, currency: requested } = c.req.valid("param");
|
|
265
|
+
const code = currency(config, requested);
|
|
266
|
+
const query = c.req.valid("query");
|
|
267
|
+
const page = await listTransactions(db(c), userId, code, query);
|
|
268
|
+
await record(c, LedgerAuditActions.transactionsRead, `${userId}:${code}`, {
|
|
269
|
+
userId,
|
|
270
|
+
currency: code,
|
|
271
|
+
returned: page.items.length,
|
|
272
|
+
resumed: query.cursor !== undefined,
|
|
273
|
+
});
|
|
274
|
+
return c.json(
|
|
275
|
+
{
|
|
276
|
+
userId,
|
|
277
|
+
currency: code,
|
|
278
|
+
transactions: page.items.map(transactionView),
|
|
279
|
+
nextCursor: page.nextCursor,
|
|
280
|
+
} satisfies LedgerTransactionsResponse,
|
|
281
|
+
200,
|
|
282
|
+
);
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// The player surface: bearer or session, always scoped to the caller.
|
|
287
|
+
|
|
288
|
+
app.get(
|
|
289
|
+
`${base}/:currency/transactions`,
|
|
290
|
+
requireAuth(),
|
|
291
|
+
zValidator("param", CurrencyParam, validationHook),
|
|
292
|
+
async (c) => {
|
|
293
|
+
const code = currency(config, c.req.valid("param").currency);
|
|
294
|
+
const rows = await openLedger(db(c)).transactions(callerId(c), code, 50);
|
|
295
|
+
return c.json({ transactions: rows }, 200);
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
app.get(`${base}/:currency`, requireAuth(), zValidator("param", CurrencyParam, validationHook), async (c) => {
|
|
300
|
+
const code = currency(config, c.req.valid("param").currency);
|
|
301
|
+
return c.json(await openLedger(db(c)).balance(callerId(c), code), 200);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
app.post(
|
|
305
|
+
`${base}/:currency/credit`,
|
|
306
|
+
requireAuth(),
|
|
307
|
+
requireAdmin(config.adminScope),
|
|
308
|
+
zValidator("param", CurrencyParam, validationHook),
|
|
309
|
+
zValidator("json", AdminWrite, validationHook),
|
|
310
|
+
async (c) => {
|
|
311
|
+
const code = currency(config, c.req.valid("param").currency);
|
|
312
|
+
const input = c.req.valid("json");
|
|
313
|
+
const balance = await openLedger(db(c)).credit(input.userId, code, input.amount, input.ref, {
|
|
314
|
+
memo: input.memo,
|
|
315
|
+
});
|
|
316
|
+
return c.json(balance, 200);
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
app.post(
|
|
321
|
+
`${base}/:currency/debit`,
|
|
322
|
+
requireAuth(),
|
|
323
|
+
requireAdmin(config.adminScope),
|
|
324
|
+
zValidator("param", CurrencyParam, validationHook),
|
|
325
|
+
zValidator("json", AdminWrite, validationHook),
|
|
326
|
+
async (c) => {
|
|
327
|
+
const code = currency(config, c.req.valid("param").currency);
|
|
328
|
+
const input = c.req.valid("json");
|
|
329
|
+
const balance = await openLedger(db(c)).debit(input.userId, code, input.amount, input.ref, {
|
|
330
|
+
memo: input.memo,
|
|
331
|
+
});
|
|
332
|
+
return c.json(balance, 200);
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
};
|
|
336
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { MAX_PAGE_SIZE } from "@pithy-sh/core/src/data/cursor";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The HTTP boundary shapes for the ledger routes. Everything a client can send is declared here and
|
|
9
|
+
* parsed on the route line, so reading a route tells you what it accepts without opening the handler.
|
|
10
|
+
*
|
|
11
|
+
* {@link CurrencyParam} is deliberately a **shape** check, not an existence check. Whether a currency is
|
|
12
|
+
* configured is a config-time question the handler still answers through `resolveCurrency`, which raises
|
|
13
|
+
* `ledger/currency_not_found` — a 404, because an unconfigured `doubloons` is a missing resource, not a
|
|
14
|
+
* malformed request. Building this schema from the configured codes would collapse that distinction.
|
|
15
|
+
* Every currency shape below reuses the same field for exactly that reason.
|
|
16
|
+
*
|
|
17
|
+
* Note what is absent from {@link AdminWrite}: the currency (it is the path) and the caller (it is the
|
|
18
|
+
* AuthContext seam). A write names the *player whose balance moves*, which is why the route is gated on
|
|
19
|
+
* the admin scope — a client that could name any `userId` without it could credit itself.
|
|
20
|
+
*
|
|
21
|
+
* The `Admin*` shapes ending in `Query`/`Param` belong to the **control-plane** management routes, which
|
|
22
|
+
* take no bodies at all: the ledger's management surface is read-only, so there is nothing for a client
|
|
23
|
+
* to send but filters and a place to resume.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Mirrors the config-time currency-code pattern: lowercase, digits, and dashes. */
|
|
27
|
+
const CURRENCY_CODE_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
28
|
+
|
|
29
|
+
/** A currency code, shape-checked. Whether it is configured stays the handler's 404. */
|
|
30
|
+
const CurrencyCode = z
|
|
31
|
+
.string()
|
|
32
|
+
.min(1)
|
|
33
|
+
.max(64)
|
|
34
|
+
.regex(CURRENCY_CODE_PATTERN, "A currency code is lowercase, digits, and dashes.");
|
|
35
|
+
|
|
36
|
+
/** A user id as a path segment — opaque to the ledger, which never issues one. */
|
|
37
|
+
const UserId = z.string().min(1).max(255);
|
|
38
|
+
|
|
39
|
+
/** Where a keyset page resumes. Opaque; a malformed one is a first page rather than an error. */
|
|
40
|
+
const Cursor = z
|
|
41
|
+
.string()
|
|
42
|
+
.max(512)
|
|
43
|
+
.optional()
|
|
44
|
+
.describe("Where to resume, from the previous page's `nextCursor`. Opaque; a malformed one is a first page.");
|
|
45
|
+
|
|
46
|
+
/** How many rows one page returns. Bounded, because a verified client can still have a bug. */
|
|
47
|
+
const Limit = z.coerce
|
|
48
|
+
.number()
|
|
49
|
+
.int()
|
|
50
|
+
.min(1)
|
|
51
|
+
.max(MAX_PAGE_SIZE)
|
|
52
|
+
.optional()
|
|
53
|
+
.describe(`How many rows to return, from 1 to ${MAX_PAGE_SIZE}. Defaults to a page a dashboard can render.`);
|
|
54
|
+
|
|
55
|
+
export const CurrencyParam = z
|
|
56
|
+
.object({
|
|
57
|
+
currency: CurrencyCode.describe(
|
|
58
|
+
"The currency this request applies to — the `:currency` path segment. A shape check only; whether the code is configured is the handler's 404.",
|
|
59
|
+
),
|
|
60
|
+
})
|
|
61
|
+
.describe("The `:currency` path segment every ledger route carries.");
|
|
62
|
+
export type CurrencyParam = z.infer<typeof CurrencyParam>;
|
|
63
|
+
|
|
64
|
+
export const AdminAccountsQuery = z
|
|
65
|
+
.object({
|
|
66
|
+
currency: CurrencyCode.optional().describe(
|
|
67
|
+
"Restrict the listing to one currency. A shape check only; an unconfigured code is the handler's 404, so an operator who mistypes is told rather than shown an empty pane.",
|
|
68
|
+
),
|
|
69
|
+
cursor: Cursor,
|
|
70
|
+
limit: Limit,
|
|
71
|
+
})
|
|
72
|
+
.describe("The account-listing query: which currency, and where to resume.");
|
|
73
|
+
export type AdminAccountsQuery = z.infer<typeof AdminAccountsQuery>;
|
|
74
|
+
|
|
75
|
+
export const AdminUserParam = z
|
|
76
|
+
.object({
|
|
77
|
+
userId: UserId.describe(
|
|
78
|
+
"The player whose balances to read — the `:userId` path segment. Opaque to the ledger: it is whatever id the adopter's auth capability issued.",
|
|
79
|
+
),
|
|
80
|
+
})
|
|
81
|
+
.describe("The `:userId` path segment on the per-player management routes.");
|
|
82
|
+
export type AdminUserParam = z.infer<typeof AdminUserParam>;
|
|
83
|
+
|
|
84
|
+
export const AdminAccountParam = z
|
|
85
|
+
.object({
|
|
86
|
+
userId: UserId.describe("The player whose account this is — the `:userId` path segment."),
|
|
87
|
+
currency: CurrencyCode.describe(
|
|
88
|
+
"The currency the account is in — the `:currency` path segment. A shape check only; an unconfigured code is the handler's 404.",
|
|
89
|
+
),
|
|
90
|
+
})
|
|
91
|
+
.describe("The `(userId, currency)` pair that addresses one account — the key the accounts table is on.");
|
|
92
|
+
export type AdminAccountParam = z.infer<typeof AdminAccountParam>;
|
|
93
|
+
|
|
94
|
+
export const AdminTransactionsQuery = z
|
|
95
|
+
.object({ cursor: Cursor, limit: Limit })
|
|
96
|
+
.describe("The entry-log query: where to resume, and how much of it to return.");
|
|
97
|
+
export type AdminTransactionsQuery = z.infer<typeof AdminTransactionsQuery>;
|
|
98
|
+
|
|
99
|
+
export const AdminWrite = z
|
|
100
|
+
.object({
|
|
101
|
+
userId: z.string().min(1).max(255).describe("The player whose balance moves."),
|
|
102
|
+
amount: z.number().int().positive().describe("A positive integer in the currency's minor unit."),
|
|
103
|
+
ref: z.string().min(1).max(255).describe("A unique idempotency key — a replay with the same ref is a no-op."),
|
|
104
|
+
memo: z.string().max(1000).optional().describe("An optional human-readable note."),
|
|
105
|
+
})
|
|
106
|
+
.describe("An admin credit/debit request.");
|
|
107
|
+
export type AdminWrite = z.infer<typeof AdminWrite>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { AdminRoute } from "@pithy-sh/core/src/controlPlane/discovery/adminRoute";
|
|
5
|
+
import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The ledger's control-plane scopes, and the admin surface a manifest advertises.
|
|
9
|
+
*
|
|
10
|
+
* **Separate from `guards.ts` because a scope name is a client's business** (#315). A management
|
|
11
|
+
* client reads these to render what a connection may do, and `pithy-sh/dashboard`'s scope builder
|
|
12
|
+
* writes the `pithy dashboard connect --scope …` command from exactly these constants — in a browser
|
|
13
|
+
* program, with the DOM lib and no Workers types. While they sat beside the Hono middleware, naming
|
|
14
|
+
* one compiled `PithyHonoEnv`, which reached core's `capability.ts`, which named Worker globals that
|
|
15
|
+
* program has none of. **This module imports types and nothing else, and a gate holds it there**:
|
|
16
|
+
* `tooling/browser-scopes` compiles a DOM-only program against every scope the kit declares.
|
|
17
|
+
*
|
|
18
|
+
* ## Two scopes, not one admin flag
|
|
19
|
+
*
|
|
20
|
+
* `scopeCovers` matches exactly, with no prefix or wildcard rule, so these two confer nothing about
|
|
21
|
+
* each other — and they should not, because they disclose different things. A **balance** is a number:
|
|
22
|
+
* what an account holds right now. An **entry log** is a behavioral record: every wager placed, every
|
|
23
|
+
* payout taken, every purchase, in order, with whatever note the adopter's own code wrote on it. A
|
|
24
|
+
* balances pane and a support tool answering "why is my chip count wrong" need different halves of
|
|
25
|
+
* that, and an adopter who wants to hand out only the shallower one must have a way to say so.
|
|
26
|
+
*
|
|
27
|
+
* The names are constants rather than config: a configurable scope name is a way to misconfigure a
|
|
28
|
+
* default-denied gate into a differently-named one, and tooling that read the docs would then hold a
|
|
29
|
+
* scope nothing checks. They are also the join key with what `pithy dashboard connect` offers an
|
|
30
|
+
* adopter to grant, so they must be the same strings in both places.
|
|
31
|
+
*
|
|
32
|
+
* ## There is no write scope, and that is the whole design
|
|
33
|
+
*
|
|
34
|
+
* Nothing here grants a management client the ability to move a balance. Adjusting a ledger from an
|
|
35
|
+
* admin console needs the same care as any other movement — an idempotency key, an audited reason, a
|
|
36
|
+
* reversal path — and shipping a `POST /adjust` that has none of them would make the console the one
|
|
37
|
+
* place the ledger's guarantees do not hold. Balance movement stays server-authoritative: in-process
|
|
38
|
+
* through `openLedger`, or over the existing player-facing admin routes behind the auth scope.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Read balances: the account list, and one player's position in each currency. A number, not a story. */
|
|
42
|
+
export const LEDGER_ACCOUNTS_READ_SCOPE: ControlPlaneScope = "ledger:accounts:read";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read the append-only entry log behind a balance — every movement, in order, with its memo. Strictly
|
|
46
|
+
* more disclosure than the balance itself, which is why it is granted separately.
|
|
47
|
+
*/
|
|
48
|
+
export const LEDGER_TRANSACTIONS_READ_SCOPE: ControlPlaneScope = "ledger:transactions:read";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Every control-plane scope the ledger defines — what `pithy dashboard connect` offers for this
|
|
52
|
+
* capability, and the list a manifest or a doc quotes rather than re-typing.
|
|
53
|
+
*/
|
|
54
|
+
export const LEDGER_CONTROL_PLANE_SCOPES: readonly ControlPlaneScope[] = [
|
|
55
|
+
LEDGER_ACCOUNTS_READ_SCOPE,
|
|
56
|
+
LEDGER_TRANSACTIONS_READ_SCOPE,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The ledger's management surface, as `GET /control-plane/manifest` reports it.
|
|
61
|
+
*
|
|
62
|
+
* Declared beside the scopes rather than in `routes.ts` so the scope a route demands and the scope a
|
|
63
|
+
* manifest advertises are the same constant, read from one place. `basePath` is a parameter and never a
|
|
64
|
+
* default: an adopter who mounted the ledger at `/wallet` must get a manifest naming
|
|
65
|
+
* `/wallet/admin/accounts`, or a management client composing its calls from it would 404 against
|
|
66
|
+
* exactly the adopters who customized anything.
|
|
67
|
+
*
|
|
68
|
+
* **Everything sits under an `admin/` segment because the player surface already owns the one-segment
|
|
69
|
+
* space.** `GET ${basePath}/:currency` matches any single segment, so a management route mounted at
|
|
70
|
+
* `${basePath}/accounts` would collide with a currency called `accounts` and, worse, would sit behind
|
|
71
|
+
* whichever of the two Hono matched first. The extra segment removes the ambiguity by construction
|
|
72
|
+
* rather than by registration order.
|
|
73
|
+
*/
|
|
74
|
+
export function ledgerAdminRoutes(basePath: string): AdminRoute[] {
|
|
75
|
+
return [
|
|
76
|
+
{
|
|
77
|
+
method: "GET",
|
|
78
|
+
path: `${basePath}/admin/accounts`,
|
|
79
|
+
scope: LEDGER_ACCOUNTS_READ_SCOPE,
|
|
80
|
+
summary: "Page every account holding a balance, newest first, optionally in one currency.",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
method: "GET",
|
|
84
|
+
path: `${basePath}/admin/accounts/:userId`,
|
|
85
|
+
scope: LEDGER_ACCOUNTS_READ_SCOPE,
|
|
86
|
+
summary: "What one player holds, in every currency they have an account in.",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
method: "GET",
|
|
90
|
+
path: `${basePath}/admin/accounts/:userId/:currency/transactions`,
|
|
91
|
+
scope: LEDGER_TRANSACTIONS_READ_SCOPE,
|
|
92
|
+
summary: "The entry log behind one balance — every movement, newest first, with its memo.",
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
}
|
package/src/http/view.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { LedgerAccount } from "../data/account";
|
|
5
|
+
import type { LedgerTransaction } from "../data/transaction";
|
|
6
|
+
import type { LedgerAccountView, LedgerTransactionView } from "./responses";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What a management client is shown. Deliberate projections, never a raw row.
|
|
10
|
+
*
|
|
11
|
+
* ## What the ledger holds, and therefore what this can leak
|
|
12
|
+
*
|
|
13
|
+
* The ledger stores no names, no email addresses, and no payment instruments — it never has any. The
|
|
14
|
+
* only identity field on either table is `userId`, the opaque id the adopter's auth capability issued,
|
|
15
|
+
* and a management client already has to name one to read anything about a person. So the PII question
|
|
16
|
+
* here is not "which column is sensitive" but **"what does the shape of somebody's balance history say
|
|
17
|
+
* about them"**, and the answer is: a great deal. That is a scope decision rather than a projection one,
|
|
18
|
+
* and it is made in `guards.ts`.
|
|
19
|
+
*
|
|
20
|
+
* ## What is dropped, and why
|
|
21
|
+
*
|
|
22
|
+
* - **`id`, on both rows.** An autoincrement primary key, described in both schemas as internal. A
|
|
23
|
+
* management client addresses an account by `(userId, currency)` and an entry by `ref`, both of which
|
|
24
|
+
* are stable and meaningful; the surrogate key is neither, and putting it in a response is how a
|
|
25
|
+
* client comes to depend on it. Position in the list is the cursor's job.
|
|
26
|
+
* - **`userId`, on an entry.** Every entry in a page came from one account, and the route named that
|
|
27
|
+
* account. Repeating it on each row would be noise a client has to be trusted to ignore.
|
|
28
|
+
*
|
|
29
|
+
* ## What is kept, and why
|
|
30
|
+
*
|
|
31
|
+
* `ref`, `relatedRef`, and `memo` are adopter-authored strings, and they are the entire reason the
|
|
32
|
+
* entry log is worth reading: `ref` is what correlates a movement with the purchase or the hand that
|
|
33
|
+
* caused it, `relatedRef` is what ties a capture back to its hold and one side of a transfer to the
|
|
34
|
+
* other, and `memo` is the sentence an operator answering "why is my chip count wrong" actually needs.
|
|
35
|
+
* An adopter who writes something sensitive into a memo has put it in their own ledger; what this
|
|
36
|
+
* decides is that reading them is its own scope, not that they are unreadable.
|
|
37
|
+
*
|
|
38
|
+
* Dates render as ISO-8601 strings. They are ms-epoch integers in SQLite and `Date`s in TypeScript, and
|
|
39
|
+
* a JSON number would leave every client guessing which unit it was in.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* ## The field lists live in `responses.ts`
|
|
44
|
+
*
|
|
45
|
+
* Both view types are `z.output` of the Zod objects there, so there is one declaration of what a
|
|
46
|
+
* client receives rather than an interface here and a hand-written mirror of it in every management
|
|
47
|
+
* client. A field added to one and not the other does not compile.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/** Project one account row for a management client. */
|
|
51
|
+
export function accountView(account: LedgerAccount): LedgerAccountView {
|
|
52
|
+
return {
|
|
53
|
+
userId: account.userId,
|
|
54
|
+
currency: account.currency,
|
|
55
|
+
balance: account.balance,
|
|
56
|
+
held: account.held,
|
|
57
|
+
available: account.balance - account.held,
|
|
58
|
+
createdAt: account.createdAt.toISOString(),
|
|
59
|
+
updatedAt: account.updatedAt.toISOString(),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Project one ledger entry for a management client. */
|
|
64
|
+
export function transactionView(entry: LedgerTransaction): LedgerTransactionView {
|
|
65
|
+
return {
|
|
66
|
+
ref: entry.ref,
|
|
67
|
+
kind: entry.kind,
|
|
68
|
+
currency: entry.currency,
|
|
69
|
+
amount: entry.amount,
|
|
70
|
+
relatedRef: entry.relatedRef,
|
|
71
|
+
memo: entry.memo,
|
|
72
|
+
createdAt: entry.createdAt.toISOString(),
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The package entrypoint — the surface `pithy add ledger` wires into `pithy.config.ts`. Deliberately
|
|
6
|
+
* narrow: the capability factory, its config and options types, the `openLedger` primitive other
|
|
7
|
+
* capabilities call in-process, and the read shapes an app renders. Every other module is by deep path
|
|
8
|
+
* (`@pithy-sh/ledger/src/...`); this is the documented contract, not a barrel over the package.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
isLedgerCapability,
|
|
13
|
+
LEDGER_MIGRATION_ORDER,
|
|
14
|
+
type LedgerCapability,
|
|
15
|
+
type LedgerOptions,
|
|
16
|
+
ledger,
|
|
17
|
+
} from "./capability";
|
|
18
|
+
export { LedgerConfig, type LedgerConfigInput, LedgerCurrency, resolveCurrency } from "./config/config";
|
|
19
|
+
export type { Balance } from "./data/account";
|
|
20
|
+
export { LedgerAccount } from "./data/account";
|
|
21
|
+
export { LedgerHold } from "./data/hold";
|
|
22
|
+
export { LedgerTransaction, TransactionKind } from "./data/transaction";
|
|
23
|
+
export { type Ledger, openLedger } from "./ledger";
|