@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,48 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+
7
+ /** What a ledger entry represents. The append-only log records every balance movement by kind. */
8
+ export const TransactionKind = z
9
+ .enum(["credit", "debit", "hold", "release", "capture", "transfer_in", "transfer_out"])
10
+ .describe(
11
+ "The movement this entry records: `credit`/`debit` add or remove funds; `hold`/`release`/`capture` open, cancel, or finalize an escrow; `transfer_in`/`transfer_out` are the two sides of a transfer.",
12
+ );
13
+ export type TransactionKind = z.infer<typeof TransactionKind>;
14
+
15
+ /**
16
+ * One entry in the append-only ledger — the row in `pithy_ledger_transactions`. Never updated or deleted:
17
+ * the account's materialized balance is the running total, and this log is the auditable history that
18
+ * explains it.
19
+ *
20
+ * `ref` is the caller-supplied idempotency key, unique across the whole ledger. Replaying an operation with
21
+ * a `ref` that already exists is a no-op — the unique constraint aborts the retry's transaction, so a
22
+ * payout or a debit applies exactly once however many times it is delivered.
23
+ */
24
+ export const LedgerTransaction = z
25
+ .object({
26
+ id: z.number().int().describe("Autoincrement primary key. Internal only."),
27
+ ref: z
28
+ .string()
29
+ .describe("The caller-supplied idempotency key, unique across the ledger. A replay of the same ref is a no-op."),
30
+ userId: z.string().describe("The account owner this entry moved funds for."),
31
+ currency: z.string().describe("The currency code this entry is in."),
32
+ kind: TransactionKind.describe("What this entry represents."),
33
+ amount: z
34
+ .number()
35
+ .int()
36
+ .describe("The movement's magnitude in the currency's minor unit — always positive; `kind` gives direction."),
37
+ relatedRef: z
38
+ .string()
39
+ .nullable()
40
+ .describe(
41
+ "Links this entry to another: a `release`/`capture` to its `hold`'s ref, or the paired sides of a transfer.",
42
+ ),
43
+ memo: z.string().nullable().describe("An optional human-readable note — `blackjack payout`, `daily bonus`."),
44
+ createdAt: SQLiteDate.describe("When this entry was recorded."),
45
+ })
46
+ .describe("One entry in the append-only ledger — the row in `pithy_ledger_transactions`.");
47
+ export type LedgerTransaction = z.output<typeof LedgerTransaction>;
48
+ export type LedgerTransactionRow = z.input<typeof LedgerTransaction>;
@@ -0,0 +1,119 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
6
+
7
+ /**
8
+ * `@pithy-sh/ledger` throw sugar. The `ledger/*` codes live in core's closed `KitErrorPayload` union
9
+ * (CLAUDE.md §Errors); these subclasses are the package-local vehicles that set one of those members.
10
+ * Runtime code in this package throws one of these, never a plain `new Error`.
11
+ */
12
+
13
+ interface LedgerErrorArgs {
14
+ message?: string;
15
+ action?: string;
16
+ detail?: string;
17
+ /**
18
+ * Values a translating client interpolates into its own wording for this code. Client-facing, so —
19
+ * unlike `action` and `detail` — these cross the boundary with `message`.
20
+ */
21
+ params?: MessageParams;
22
+ }
23
+
24
+ export class LedgerCurrencyNotFoundError extends PithyError {
25
+ constructor(args: LedgerErrorArgs = {}, options?: { cause?: unknown }) {
26
+ super(
27
+ {
28
+ code: "ledger/currency_not_found",
29
+ status: 404,
30
+ message: args.message ?? "That currency does not exist.",
31
+ action: args.action ?? "Check the currency code against the `currencies` list in pithy.config.ts.",
32
+ detail: args.detail,
33
+ params: args.params,
34
+ },
35
+ options,
36
+ );
37
+ }
38
+ }
39
+
40
+ export class LedgerAccountNotFoundError extends PithyError {
41
+ constructor(args: LedgerErrorArgs = {}, options?: { cause?: unknown }) {
42
+ super(
43
+ {
44
+ code: "ledger/account_not_found",
45
+ status: 404,
46
+ message: args.message ?? "You have no account in that currency yet.",
47
+ action: args.action ?? "Fund the account first — a credit opens it.",
48
+ detail: args.detail,
49
+ params: args.params,
50
+ },
51
+ options,
52
+ );
53
+ }
54
+ }
55
+
56
+ export class LedgerHoldNotFoundError extends PithyError {
57
+ constructor(args: LedgerErrorArgs = {}, options?: { cause?: unknown }) {
58
+ super(
59
+ {
60
+ code: "ledger/hold_not_found",
61
+ status: 404,
62
+ message: args.message ?? "That hold does not exist.",
63
+ action: args.action ?? "Check the hold reference.",
64
+ detail: args.detail,
65
+ params: args.params,
66
+ },
67
+ options,
68
+ );
69
+ }
70
+ }
71
+
72
+ export class LedgerInsufficientFundsError extends PithyError {
73
+ constructor(args: LedgerErrorArgs = {}, options?: { cause?: unknown }) {
74
+ super(
75
+ {
76
+ code: "ledger/insufficient_funds",
77
+ status: 409,
78
+ message: args.message ?? "Not enough funds.",
79
+ action: args.action ?? "Reduce the amount, or top up the balance.",
80
+ detail: args.detail,
81
+ params: args.params,
82
+ },
83
+ options,
84
+ );
85
+ }
86
+ }
87
+
88
+ export class LedgerHoldNotOpenError extends PithyError {
89
+ constructor(args: LedgerErrorArgs = {}, options?: { cause?: unknown }) {
90
+ super(
91
+ {
92
+ code: "ledger/hold_not_open",
93
+ status: 409,
94
+ message: args.message ?? "That hold has already been resolved.",
95
+ action: args.action ?? "A hold can be released or captured once; check its status.",
96
+ detail: args.detail,
97
+ params: args.params,
98
+ },
99
+ options,
100
+ );
101
+ }
102
+ }
103
+
104
+ export class LedgerInvalidAmountError extends PithyError {
105
+ constructor(args: LedgerErrorArgs = {}, options?: { cause?: unknown }) {
106
+ super(
107
+ {
108
+ code: "ledger/invalid_amount",
109
+ status: 400,
110
+ message: args.message ?? "Amount must be a positive whole number.",
111
+ action:
112
+ args.action ?? "Amounts are integers in the currency's minor unit — never zero, negative, or fractional.",
113
+ detail: args.detail,
114
+ params: args.params,
115
+ },
116
+ options,
117
+ );
118
+ }
119
+ }
@@ -0,0 +1,79 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
5
+ import { ForbiddenError, UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { MiddlewareHandler } from "hono";
7
+
8
+ /**
9
+ * The ledger routes' identity gates: the two middlewares this package owns.
10
+ *
11
+ * **The scope constants moved to `./scopes`** (#315). A management client reads them to render what a
12
+ * connection may do, and it reads them in a browser — so they cannot live in a module that imports
13
+ * Hono middleware and `PithyHonoEnv`. The gates stayed here; the names they demand are next door, and
14
+ * the routes import both.
15
+ *
16
+ * ## `requireAuth` is copied, not imported
17
+ *
18
+ * These lines are the same ones `@pithy-sh/payments`, `@pithy-sh/storage`, and `@pithy-sh/media` each
19
+ * carry, and the duplication is deliberate. Importing the gate from `@pithy-sh/auth` would make auth a
20
+ * hard dependency, and *a package that imports its authorization from another package fails open when
21
+ * that package is absent*. Depending on the core `AuthContext` seam instead means `c.var.auth` is simply
22
+ * `null` with no auth capability composed, and every player route denies. Failing closed is not a side
23
+ * effect of the copy; it is the reason for it.
24
+ *
25
+ * ## The control-plane gate is core's, and the ledger contributes only the scope names
26
+ *
27
+ * `requireControlPlane` lives in `@pithy-sh/core/src/controlPlane/http/guard` and the management routes
28
+ * wear it directly. The ledger verifies nothing itself: a management call arrives as an EdDSA-signed
29
+ * compact JWS on the `pithy-control-plane` header, and the seam checks the signature against a public
30
+ * key the **adopter** registered, the connection it addresses, that connection's environment, the
31
+ * token's lifetime, a digest of the body, and the token's single use.
32
+ *
33
+ * **This is not the opposite of the rule above; it is the same rule.** The rule is never to import
34
+ * authorization from a package that might be absent. `@pithy-sh/auth` is optional, so its gate is
35
+ * copied. `@pithy-sh/core` is a hard dependency of every capability there is, so importing its gate
36
+ * cannot leave a deployment without one — and when the *seam* is not composed the imported gate raises
37
+ * `controlplane/not_connected` rather than passing.
38
+ *
39
+ * ## `requireAuth()` never sits on a management route, and `requireAdmin` never replaces the seam
40
+ *
41
+ * A management client is not a player: it holds no session, owns no account row, and the seam
42
+ * deliberately leaves `c.var.auth` null so a control-plane credential cannot satisfy an ordinary
43
+ * `requireAuth()` anywhere in the tree. So {@link requireAdmin} — which reads `c.var.auth?.scopes` —
44
+ * can never pass for one, by design. The control-plane scope **replaces** that gate on the management
45
+ * routes; it does not stack with it. Stacking them would deny every legitimate management call,
46
+ * permanently, and no credential could fix it.
47
+ */
48
+
49
+ /** Require an authenticated caller (the core AuthContext seam). */
50
+ export function requireAuth(): MiddlewareHandler<PithyHonoEnv> {
51
+ return async (c, next) => {
52
+ if (!c.var.auth) {
53
+ throw new UnauthorizedError({
54
+ message: "Authentication required.",
55
+ action: "Sign in and retry with a valid session or bearer token.",
56
+ });
57
+ }
58
+ await next();
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Require the admin scope for a balance-moving write.
64
+ *
65
+ * Reads `c.var.auth`, so it gates the **player-facing** trusted-server routes and nothing else. A
66
+ * control-plane caller has no `AuthContext` by design and can never satisfy it — see the file comment.
67
+ */
68
+ export function requireAdmin(scope: string): MiddlewareHandler<PithyHonoEnv> {
69
+ return async (c, next) => {
70
+ if (!c.var.auth?.scopes.includes(scope)) {
71
+ throw new ForbiddenError({
72
+ message: "This session may not move balances.",
73
+ action: `Retry with a token carrying the ${scope} scope, minted for your trusted server.`,
74
+ detail: `Ledger writes require the ${scope} scope; this session carries [${c.var.auth?.scopes.join(", ") ?? ""}].`,
75
+ });
76
+ }
77
+ await next();
78
+ };
79
+ }
@@ -0,0 +1,108 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { TransactionKind } from "../data/transaction";
6
+
7
+ /**
8
+ * What the ledger's management routes return, as Zod objects a management client can validate against.
9
+ *
10
+ * `schemas.ts` bounds what a caller may send; this file states what it gets back. Both halves are
11
+ * runtime values for the same reason: a management client reading a customer's Worker is crossing a
12
+ * trust boundary and must validate what comes back, and a TypeScript interface is erased before it
13
+ * can help — so every client that had only an interface hand-wrote a mirror, and the mirror drifted
14
+ * the first time a field landed here.
15
+ *
16
+ * **No codecs, and no transform anywhere in this file.** These describe JSON on the wire, so parsing
17
+ * one hands back exactly what went in — which is what lets `responses.test.ts` compare a parsed value
18
+ * with the projection's output and fail on a field either side forgot.
19
+ *
20
+ * The projections live in `view.ts`, which documents *why* each surrogate key is dropped. This file is
21
+ * the shape; that file is the argument.
22
+ *
23
+ * **A field added here later is `.optional()`, not merely `.nullable()`.** This module is read across a
24
+ * version boundary — a management client validates a response with this schema against a customer's
25
+ * Worker at whatever kit version it is on — so an additive required key fails `safeParse` for everyone
26
+ * below that release and takes the whole pane with it (#450). Absent then means *this Worker cannot
27
+ * say*, which is a different fact from `null`.
28
+ */
29
+
30
+ /** Where a page resumes, or the end of the list. */
31
+ const NextCursor = z
32
+ .string()
33
+ .nullable()
34
+ .describe("Where the next page resumes. Null at the end of the list. Opaque — pass it back verbatim.");
35
+
36
+ /** One account, as a management client sees it. */
37
+ export const LedgerAccountView = z
38
+ .object({
39
+ userId: z.string().describe("The account owner — the opaque user id the adopter's auth capability issued."),
40
+ currency: z.string().describe("The currency this balance is in."),
41
+ balance: z.number().int().describe("Total owned, in the currency's minor unit."),
42
+ held: z.number().int().describe("The portion reserved by open holds."),
43
+ available: z
44
+ .number()
45
+ .int()
46
+ .describe("Spendable now (`balance - held`). Computed, so a client never has to know the rule."),
47
+ createdAt: z.iso.datetime().describe("When the account was opened, ISO-8601."),
48
+ updatedAt: z.iso.datetime().describe("When the balance last changed, ISO-8601."),
49
+ })
50
+ .describe("One account as a management client sees it. Addressed by `(userId, currency)`, never by a surrogate id.");
51
+ export type LedgerAccountView = z.output<typeof LedgerAccountView>;
52
+
53
+ /** One ledger entry, as a management client sees it. */
54
+ export const LedgerTransactionView = z
55
+ .object({
56
+ ref: z
57
+ .string()
58
+ .describe("The caller-supplied idempotency key, unique across the ledger — the entry's stable identifier."),
59
+ kind: TransactionKind.describe("What the movement was."),
60
+ currency: z.string().describe("The currency the movement was in."),
61
+ amount: z
62
+ .number()
63
+ .int()
64
+ .describe("The movement's magnitude in the minor unit — always positive; `kind` gives direction."),
65
+ relatedRef: z
66
+ .string()
67
+ .nullable()
68
+ .describe("The entry this one answers: a hold's ref, or the other side of a transfer. Null when standalone."),
69
+ memo: z.string().nullable().describe("The adopter's own note on the movement, or null."),
70
+ createdAt: z.iso.datetime().describe("When the movement was recorded, ISO-8601."),
71
+ })
72
+ .describe("One ledger entry as a management client sees it. No surrogate id, and no repeated owner.");
73
+ export type LedgerTransactionView = z.output<typeof LedgerTransactionView>;
74
+
75
+ /** `GET {base}/admin/accounts`. */
76
+ export const LedgerAccountsResponse = z
77
+ .object({
78
+ accounts: z.array(LedgerAccountView).describe("The page, most recently changed first."),
79
+ nextCursor: NextCursor,
80
+ })
81
+ .describe("A page of every account this ledger holds.");
82
+ export type LedgerAccountsResponse = z.output<typeof LedgerAccountsResponse>;
83
+
84
+ /**
85
+ * `GET {base}/admin/accounts/:userId`.
86
+ *
87
+ * An empty list rather than a 404 for a player who holds nothing: an account is opened by its first
88
+ * credit, so its absence is not a missing person — and answering 404 would make this surface an
89
+ * existence oracle for user ids.
90
+ */
91
+ export const LedgerUserAccountsResponse = z
92
+ .object({
93
+ userId: z.string().describe("The player asked after, echoed so a response stands on its own."),
94
+ accounts: z.array(LedgerAccountView).describe("Every currency this player holds. Empty when they hold none."),
95
+ })
96
+ .describe("One player's balances, in every currency they hold.");
97
+ export type LedgerUserAccountsResponse = z.output<typeof LedgerUserAccountsResponse>;
98
+
99
+ /** `GET {base}/admin/accounts/:userId/:currency/transactions`. */
100
+ export const LedgerTransactionsResponse = z
101
+ .object({
102
+ userId: z.string().describe("The account owner, echoed so a response stands on its own."),
103
+ currency: z.string().describe("The currency, as the configured catalog spells it."),
104
+ transactions: z.array(LedgerTransactionView).describe("The page, newest first."),
105
+ nextCursor: NextCursor,
106
+ })
107
+ .describe("A page of one account's entry log.");
108
+ export type LedgerTransactionsResponse = z.output<typeof LedgerTransactionsResponse>;