@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.
@@ -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
+ }
@@ -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";