@misofm/api-client 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/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # `@misofm/api-client`
2
+
3
+ Typed client, schemas, and response contracts for the Miso API read layer.
4
+
5
+ ```ts
6
+ import { MisoApiClient } from "@misofm/api-client";
7
+
8
+ const client = new MisoApiClient({ baseUrl: "https://api.testnet.miso.fm" });
9
+ ```
10
+
11
+ The package is maintained in the [`misofm/api`](https://github.com/misofm/api)
12
+ repository. Its Zod schemas are the source of truth for responses shared by the
13
+ read service and browser clients.
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@misofm/api-client",
3
+ "version": "0.1.0",
4
+ "description": "Typed client and response contract for the Miso API read layer. The one definition of what a Miso read returns.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/misofm/api.git",
9
+ "directory": "packages/api-client"
10
+ },
11
+ "homepage": "https://github.com/misofm/api/tree/main/packages/api-client#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/misofm/api/issues"
14
+ },
15
+ "module": "src/index.ts",
16
+ "types": "src/index.ts",
17
+ "type": "module",
18
+ "sideEffects": false,
19
+ "exports": {
20
+ ".": "./src/index.ts",
21
+ "./schemas": "./src/schemas.ts",
22
+ "./types": "./src/types.ts",
23
+ "./cache": "./src/cache.ts"
24
+ },
25
+ "files": [
26
+ "src",
27
+ "README.md",
28
+ "package.json"
29
+ ],
30
+ "scripts": {
31
+ "test": "bun test",
32
+ "typecheck": "tsc --noEmit"
33
+ },
34
+ "dependencies": {
35
+ "zod": "^4.3.6"
36
+ },
37
+ "peerDependencies": {
38
+ "typescript": "^5"
39
+ },
40
+ "devDependencies": {
41
+ "@types/bun": "latest",
42
+ "typescript": "^5"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ }
47
+ }
@@ -0,0 +1,90 @@
1
+ // Copyright (c) Miso Labs, Inc.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // The cache table is a policy document that happens to be executable, so it gets
5
+ // tested as one: the exact header per class, and the invariants that make the
6
+ // classes coherent with each other.
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { CACHE_POLICIES, cacheControl, workCacheClass, type CacheClass } from "./cache.ts";
10
+
11
+ describe("cacheControl", () => {
12
+ const expected: Record<CacheClass, string> = {
13
+ immutable: "public, max-age=31536000, s-maxage=31536000, immutable",
14
+ published: "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400",
15
+ draft: "public, max-age=0, s-maxage=60, stale-while-revalidate=300",
16
+ artist: "public, max-age=30, s-maxage=60, stale-while-revalidate=600",
17
+ sale: "public, max-age=0, s-maxage=60, stale-while-revalidate=300",
18
+ private: "private, no-store",
19
+ };
20
+
21
+ for (const [cls, header] of Object.entries(expected)) {
22
+ test(`${cls} → ${header}`, () => {
23
+ expect(cacheControl(cls as CacheClass)).toBe(header);
24
+ });
25
+ }
26
+
27
+ test("private never reaches a shared cache", () => {
28
+ const header = cacheControl("private");
29
+ expect(header).not.toContain("public");
30
+ expect(header).not.toContain("s-maxage");
31
+ expect(header).toContain("no-store");
32
+ });
33
+ });
34
+
35
+ describe("policy invariants", () => {
36
+ const publicClasses = (Object.keys(CACHE_POLICIES) as CacheClass[]).filter((c) => c !== "private");
37
+
38
+ test("the edge always holds a response at least as long as a browser does", () => {
39
+ // Otherwise the browser outlives the shared copy and the edge cache is
40
+ // pointless — every client revalidation would miss.
41
+ for (const cls of publicClasses) {
42
+ const p = CACHE_POLICIES[cls];
43
+ expect(p.sMaxAge!).toBeGreaterThanOrEqual(p.maxAge);
44
+ }
45
+ });
46
+
47
+ test("staleTime mirrors the browser lifetime, so the client never refetches what it already holds fresh", () => {
48
+ for (const cls of publicClasses) {
49
+ const p = CACHE_POLICIES[cls];
50
+ if (Number.isFinite(p.staleTimeMs)) expect(p.staleTimeMs).toBe(p.maxAge * 1000);
51
+ }
52
+ expect(CACHE_POLICIES.immutable.staleTimeMs).toBe(Number.POSITIVE_INFINITY);
53
+ });
54
+
55
+ test("live sale data is among the shortest public TTLs", () => {
56
+ // No longer strictly the shortest: a pressing is UNCAPPED, so its
57
+ // copies-sold counter gates nothing and a minute of staleness is cosmetic.
58
+ // `draft` now ties it, because an editor really must see their own change.
59
+ const others = publicClasses.filter((c) => c !== "sale");
60
+ for (const cls of others) {
61
+ expect(CACHE_POLICIES.sale.sMaxAge!).toBeLessThanOrEqual(CACHE_POLICIES[cls].sMaxAge!);
62
+ }
63
+ });
64
+
65
+ test("a draft is cached far more briefly than a published work", () => {
66
+ expect(CACHE_POLICIES.draft.sMaxAge!).toBeLessThan(CACHE_POLICIES.published.sMaxAge!);
67
+ // An editor must see their own change land.
68
+ expect(CACHE_POLICIES.draft.maxAge).toBe(0);
69
+ });
70
+
71
+ test("private carries no shared lifetime at all", () => {
72
+ expect(CACHE_POLICIES.private.sMaxAge).toBeNull();
73
+ expect(CACHE_POLICIES.private.staleWhileRevalidate).toBeNull();
74
+ expect(CACHE_POLICIES.private.staleTimeMs).toBe(0);
75
+ });
76
+ });
77
+
78
+ describe("workCacheClass", () => {
79
+ test("published work caches long, draft caches short", () => {
80
+ expect(workCacheClass({ type: "Published" })).toBe("published");
81
+ expect(workCacheClass({ type: "Initialized" })).toBe("draft");
82
+ });
83
+
84
+ test("an unknown or missing state is treated as a draft", () => {
85
+ // Fail toward freshness: over-caching a work that is still moving is the
86
+ // worse error, because the artist edits and nothing happens.
87
+ expect(workCacheClass(null)).toBe("draft");
88
+ expect(workCacheClass(undefined)).toBe("draft");
89
+ });
90
+ });
package/src/cache.ts ADDED
@@ -0,0 +1,94 @@
1
+ // Copyright (c) Miso Labs, Inc.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // How long each kind of read stays good. ONE table, shared by both ends:
5
+ //
6
+ // miso-read-service turns a class into `Cache-Control` and a Cache API entry
7
+ // this client turns the same class into a TanStack Query `staleTime`
8
+ //
9
+ // They live together because a client that refetches every 30s in front of an
10
+ // edge that caches for 60s is just serving itself the cached body twice. Reading
11
+ // the same numbers keeps the browser's idea of "fresh" and the edge's identical.
12
+ //
13
+ // The classes are chosen by WHAT MOVES THE DATA, not by route:
14
+ //
15
+ // immutable nothing can change it, ever. A record's parent release is fixed
16
+ // at mint; a settled transaction is settled. Cache for a year.
17
+ // published a published work. Its metadata only moves when an artist
18
+ // deliberately re-sets an extension — rare, and a minute of
19
+ // staleness costs nobody anything.
20
+ // draft the same work before publication: actively being edited in
21
+ // studio, so the editor must see their own change land.
22
+ // artist a profile page. Owner edits should surface within a minute.
23
+ // sale a live pressing. The only field that moves between reads is the
24
+ // copies-sold counter — and a pressing is UNCAPPED (one open run,
25
+ // no editions, no supply cap, no sold-out state), so that counter
26
+ // gates nothing. It is a display number, not a stock level, which
27
+ // is why a minute of staleness is cosmetic rather than a
28
+ // correctness problem.
29
+ // private address-scoped. Never enters a shared cache — the failure mode is
30
+ // serving one user's library to another.
31
+ //
32
+ // THERE IS NO INVALIDATION. These TTLs are the whole freshness story: the cache
33
+ // is an optimization, never a source of truth, and the chain stays
34
+ // authoritative. A writer who must see their OWN change immediately appends a
35
+ // cache-buster param, which mints one throwaway entry instead of evicting the
36
+ // one everybody else is reading.
37
+
38
+ export type CacheClass = "immutable" | "published" | "draft" | "artist" | "sale" | "private";
39
+
40
+ export interface CachePolicy {
41
+ /** Shared-cache lifetime in seconds (`s-maxage`). `null` for private. */
42
+ sMaxAge: number | null;
43
+ /**
44
+ * How long past `sMaxAge` the edge may serve the stale body while it
45
+ * revalidates behind the request. This is where the latency win lives: a
46
+ * visitor after expiry gets the old body instantly instead of waiting on a
47
+ * chain round-trip.
48
+ */
49
+ staleWhileRevalidate: number | null;
50
+ /** Browser lifetime (`max-age`). Deliberately shorter than the edge's. */
51
+ maxAge: number;
52
+ /** TanStack Query `staleTime`, in ms. Mirrors `maxAge`. */
53
+ staleTimeMs: number;
54
+ }
55
+
56
+ export const CACHE_POLICIES: Record<CacheClass, CachePolicy> = {
57
+ immutable: { sMaxAge: 31_536_000, staleWhileRevalidate: null, maxAge: 31_536_000, staleTimeMs: Number.POSITIVE_INFINITY },
58
+ published: { sMaxAge: 3_600, staleWhileRevalidate: 86_400, maxAge: 300, staleTimeMs: 300_000 },
59
+ draft: { sMaxAge: 60, staleWhileRevalidate: 300, maxAge: 0, staleTimeMs: 0 },
60
+ artist: { sMaxAge: 60, staleWhileRevalidate: 600, maxAge: 30, staleTimeMs: 30_000 },
61
+ sale: { sMaxAge: 60, staleWhileRevalidate: 300, maxAge: 0, staleTimeMs: 0 },
62
+ private: { sMaxAge: null, staleWhileRevalidate: null, maxAge: 0, staleTimeMs: 0 },
63
+ };
64
+
65
+ /**
66
+ * The `Cache-Control` header for a class.
67
+ *
68
+ * `private, no-store` on the private class is doing real work: `private` alone
69
+ * would still let a browser (or a misconfigured intermediary) retain the body,
70
+ * and these responses describe one wallet's holdings.
71
+ */
72
+ export function cacheControl(cls: CacheClass): string {
73
+ const p = CACHE_POLICIES[cls];
74
+ if (p.sMaxAge === null) return "private, no-store";
75
+
76
+ const parts = ["public", `max-age=${p.maxAge}`, `s-maxage=${p.sMaxAge}`];
77
+ if (p.staleWhileRevalidate !== null) parts.push(`stale-while-revalidate=${p.staleWhileRevalidate}`);
78
+ if (cls === "immutable") parts.push("immutable");
79
+ return parts.join(", ");
80
+ }
81
+
82
+ /** TanStack Query options for a class — spread straight into `useQuery`. */
83
+ export function queryPolicy(cls: CacheClass): { staleTime: number } {
84
+ return { staleTime: CACHE_POLICIES[cls].staleTimeMs };
85
+ }
86
+
87
+ /**
88
+ * A work's class from its own state. This is why the middleware picks TTL from
89
+ * the RESPONSE rather than the route: the same `/releases/:id` path is a
90
+ * year-stable published record for one id and a live draft for another.
91
+ */
92
+ export function workCacheClass(state: { type: string } | null | undefined): CacheClass {
93
+ return state?.type === "Published" ? "published" : "draft";
94
+ }
@@ -0,0 +1,146 @@
1
+ // Copyright (c) Miso Labs, Inc.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // The card-checkout contract (Stripe on-ramp for records). Served by
5
+ // miso-platform-service under `/platform/checkout`, not by the read service — it
6
+ // is a WRITE surface with its own Durable Object payment gate — but it lives in
7
+ // this package so an app has one API dependency rather than two.
8
+ //
9
+ // Lifted from miso-app's `lib/checkout-client.ts`, which was hand-written against
10
+ // the backend and had no way to notice the backend changing.
11
+ //
12
+ // quote → sign → session → redirect to Stripe → poll the receipt
13
+
14
+ import { z } from "zod";
15
+ import { MisoApiError } from "./client.ts";
16
+
17
+ export const checkoutQuoteSchema = z.object({
18
+ /** The signed payload the buyer signs to prove intent. */
19
+ payload: z.string(),
20
+ nonce: z.string(),
21
+ /** What the card will actually be charged, in USD cents (price + card fee). */
22
+ cardAmountCents: z.number().int(),
23
+ expiresAt: z.string(),
24
+ priceDisplay: z.string(),
25
+ });
26
+
27
+ export const checkoutSessionSchema = z.object({
28
+ /** Stripe Checkout URL to redirect to. */
29
+ url: z.string().url(),
30
+ orderId: z.string(),
31
+ });
32
+
33
+ export const orderStateSchema = z.enum([
34
+ "awaiting_payment",
35
+ "paid",
36
+ "fulfilling",
37
+ "under_review",
38
+ "fulfilled",
39
+ "fulfill_failed",
40
+ "refund_pending",
41
+ "refunded",
42
+ "expired",
43
+ ]);
44
+
45
+ export const receiptLineItemSchema = z.object({
46
+ description: z.string(),
47
+ amountCents: z.number().int(),
48
+ /** ISO-4217, lowercase (always "usd"). */
49
+ currency: z.string(),
50
+ quantity: z.number().int(),
51
+ });
52
+
53
+ export const orderReceiptSchema = z.object({
54
+ state: orderStateSchema,
55
+ /** The Sui address the record is/was delivered to. */
56
+ buyerAddress: z.string(),
57
+ lineItems: z.array(receiptLineItemSchema),
58
+ /** The minted record's object id once fulfilled — the target of "Mix it now". */
59
+ recordObjectId: z.string().optional(),
60
+ txDigest: z.string().optional(),
61
+ network: z.enum(["testnet", "mainnet"]),
62
+ failureReason: z.string().optional(),
63
+ });
64
+
65
+ export type CheckoutQuote = z.infer<typeof checkoutQuoteSchema>;
66
+ export type CheckoutSession = z.infer<typeof checkoutSessionSchema>;
67
+ export type OrderState = z.infer<typeof orderStateSchema>;
68
+ export type ReceiptLineItem = z.infer<typeof receiptLineItemSchema>;
69
+ export type OrderReceipt = z.infer<typeof orderReceiptSchema>;
70
+
71
+ /**
72
+ * Terminal "you can't see this": the order uuid + session_id capability pair did
73
+ * not resolve — unknown order, wrong or expired session, or a malformed link. The
74
+ * API returns an identical 404 for all of these so nothing leaks about whether
75
+ * the order exists, which means the client can't tell them apart either.
76
+ *
77
+ * **Stop polling on this.** Any other non-2xx is transient and safe to retry.
78
+ */
79
+ export class CheckoutNotFoundError extends MisoApiError {
80
+ constructor(message: string) {
81
+ super(404, "not_found", message);
82
+ this.name = "CheckoutNotFoundError";
83
+ }
84
+ }
85
+
86
+ export interface CheckoutClientOptions {
87
+ baseUrl: string;
88
+ fetch?: typeof globalThis.fetch;
89
+ /** Where the platform service is mounted on `baseUrl`. */
90
+ prefix?: string;
91
+ }
92
+
93
+ export function createCheckoutClient(options: CheckoutClientOptions) {
94
+ const base = options.baseUrl.replace(/\/$/, "");
95
+ const prefix = options.prefix ?? "/platform/checkout";
96
+ const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
97
+
98
+ async function parseError(res: Response): Promise<MisoApiError> {
99
+ const body = (await res.json().catch(() => null)) as { error?: string | { message?: string } } | null;
100
+ const raw = body?.error;
101
+ const message = typeof raw === "string" ? raw : (raw?.message ?? `Request failed (${res.status})`);
102
+ return new MisoApiError(res.status, "checkout_error", message);
103
+ }
104
+
105
+ async function post<T>(schema: z.ZodType<T>, path: string, body: unknown): Promise<T> {
106
+ const res = await doFetch(`${base}${prefix}${path}`, {
107
+ method: "POST",
108
+ headers: { "Content-Type": "application/json" },
109
+ body: JSON.stringify(body),
110
+ });
111
+ if (!res.ok) throw await parseError(res);
112
+ return schema.parse(await res.json());
113
+ }
114
+
115
+ return {
116
+ /** Price + signed-payload quote for a drop, for a given recipient. */
117
+ getQuote: (dropId: string, recipient: string): Promise<CheckoutQuote> =>
118
+ post(checkoutQuoteSchema, "/quote", { dropId, recipient }),
119
+
120
+ /** Verify the signature + Enoki identity, then open a Stripe session. */
121
+ createSession: (params: {
122
+ payload: string;
123
+ signature: string;
124
+ address: string;
125
+ nonce: string;
126
+ jwt: string;
127
+ recordName?: string;
128
+ }): Promise<CheckoutSession> => post(checkoutSessionSchema, "/session", params),
129
+
130
+ /**
131
+ * The buyer-scoped receipt for the post-purchase page. Requires the order
132
+ * uuid AND the Stripe session id from the success redirect — a capability
133
+ * pair, not an id lookup. A 404/400 throws {@link CheckoutNotFoundError}.
134
+ */
135
+ getReceipt: async (orderId: string, sessionId: string): Promise<OrderReceipt> => {
136
+ const res = await doFetch(
137
+ `${base}${prefix}/orders/${orderId}/receipt?session_id=${encodeURIComponent(sessionId)}`,
138
+ );
139
+ if (res.status === 404 || res.status === 400) throw new CheckoutNotFoundError((await parseError(res)).message);
140
+ if (!res.ok) throw await parseError(res);
141
+ return orderReceiptSchema.parse(await res.json());
142
+ },
143
+ };
144
+ }
145
+
146
+ export type CheckoutClient = ReturnType<typeof createCheckoutClient>;
@@ -0,0 +1,201 @@
1
+ // Copyright (c) Miso Labs, Inc.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { describe, expect, test } from "bun:test";
5
+ import { cacheBuster, createMisoApiClient, MisoApiContractError, MisoApiError, READ_CACHE_CLASS } from "./client.ts";
6
+
7
+ /** A fetch that records the URL it was called with and replays a canned response. */
8
+ function stubFetch(response: { status?: number; body?: unknown }) {
9
+ const calls: string[] = [];
10
+ const fetch = (async (input: string | URL) => {
11
+ calls.push(String(input));
12
+ return new Response(response.body === undefined ? "" : JSON.stringify(response.body), {
13
+ status: response.status ?? 200,
14
+ headers: { "Content-Type": "application/json" },
15
+ });
16
+ }) as unknown as typeof globalThis.fetch;
17
+ return { fetch, calls };
18
+ }
19
+
20
+ const BASE = "https://api.testnet.miso.fm";
21
+
22
+ const balance = {
23
+ address: "0xabc",
24
+ coinType: "0x7777::fakeusd::FakeUsd",
25
+ balance: "100000000",
26
+ decimals: 6,
27
+ };
28
+
29
+ describe("URL construction", () => {
30
+ test("mounts reads under the gateway's read prefix", async () => {
31
+ const { fetch, calls } = stubFetch({ body: balance });
32
+ await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc");
33
+ expect(calls[0]).toBe(`${BASE}/read/v1/wallets/0xabc/balance`);
34
+ });
35
+
36
+ test("tolerates a trailing slash on the base URL", async () => {
37
+ const { fetch, calls } = stubFetch({ body: balance });
38
+ await createMisoApiClient({ baseUrl: `${BASE}/`, fetch }).getBalance("0xabc");
39
+ expect(calls[0]).toBe(`${BASE}/read/v1/wallets/0xabc/balance`);
40
+ });
41
+
42
+ test("omits empty query params rather than sending them blank", async () => {
43
+ const { fetch, calls } = stubFetch({ body: balance });
44
+ await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc", undefined);
45
+ expect(calls[0]).not.toContain("coinType");
46
+ });
47
+
48
+ test("sends an explicit coin type when given one", async () => {
49
+ const { fetch, calls } = stubFetch({ body: balance });
50
+ await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc", "0x2::sui::SUI");
51
+ expect(calls[0]).toContain("coinType=0x2%3A%3Asui%3A%3ASUI");
52
+ });
53
+
54
+ test("joins the artist include list into one param", async () => {
55
+ const { fetch, calls } = stubFetch({ status: 404 });
56
+ await createMisoApiClient({ baseUrl: BASE, fetch }).getArtist("0xp", { include: ["roles", "tags"] });
57
+ expect(calls[0]).toContain("include=roles%2Ctags");
58
+ });
59
+
60
+ test("a custom prefix is honored (self-hosted / direct-to-service)", async () => {
61
+ const { fetch, calls } = stubFetch({ body: balance });
62
+ await createMisoApiClient({ baseUrl: BASE, fetch, prefix: "/v1" }).getBalance("0xabc");
63
+ expect(calls[0]).toBe(`${BASE}/v1/wallets/0xabc/balance`);
64
+ });
65
+ });
66
+
67
+ describe("not-found handling", () => {
68
+ test("a superseded pressing is null, not an error", async () => {
69
+ const { fetch } = stubFetch({ status: 404, body: { error: { code: "not_found", message: "gone" } } });
70
+ expect(await createMisoApiClient({ baseUrl: BASE, fetch }).getPressing("0xdead")).toBeNull();
71
+ });
72
+
73
+ test("an unknown artist is null", async () => {
74
+ const { fetch } = stubFetch({ status: 404, body: { error: { code: "not_found", message: "gone" } } });
75
+ expect(await createMisoApiClient({ baseUrl: BASE, fetch }).getArtist("0xdead")).toBeNull();
76
+ });
77
+
78
+ test("an empty id list short-circuits without a request", async () => {
79
+ const { fetch, calls } = stubFetch({ body: [] });
80
+ expect(await createMisoApiClient({ baseUrl: BASE, fetch }).getArtists([])).toEqual([]);
81
+ expect(calls).toHaveLength(0);
82
+ });
83
+ });
84
+
85
+ describe("error handling", () => {
86
+ test("surfaces the server's error code and message", async () => {
87
+ const { fetch } = stubFetch({ status: 429, body: { error: { code: "rate-limited", message: "Too many requests." } } });
88
+ const api = createMisoApiClient({ baseUrl: BASE, fetch });
89
+ await expect(api.getDiscover()).rejects.toThrow(MisoApiError);
90
+ try {
91
+ await api.getDiscover();
92
+ } catch (e) {
93
+ expect(e).toBeInstanceOf(MisoApiError);
94
+ expect((e as MisoApiError).status).toBe(429);
95
+ expect((e as MisoApiError).code).toBe("rate-limited");
96
+ expect((e as MisoApiError).message).toBe("Too many requests.");
97
+ }
98
+ });
99
+
100
+ test("an error body in an unexpected shape still throws a usable error", async () => {
101
+ const { fetch } = stubFetch({ status: 500, body: { oops: true } });
102
+ try {
103
+ await createMisoApiClient({ baseUrl: BASE, fetch }).getDiscover();
104
+ throw new Error("should have thrown");
105
+ } catch (e) {
106
+ expect(e).toBeInstanceOf(MisoApiError);
107
+ expect((e as MisoApiError).code).toBe("unknown");
108
+ }
109
+ });
110
+
111
+ test("a 404 on an endpoint that must return a body is an error, not null", async () => {
112
+ const { fetch } = stubFetch({ status: 404, body: { error: { code: "not_found", message: "gone" } } });
113
+ await expect(createMisoApiClient({ baseUrl: BASE, fetch }).getWalletRecords("0xabc")).rejects.toThrow(MisoApiError);
114
+ });
115
+ });
116
+
117
+ describe("contract validation", () => {
118
+ test("a response missing a required field fails loudly at the boundary", async () => {
119
+ const { fetch } = stubFetch({ body: { address: "0xabc", coinType: "0x2::sui::SUI" } }); // no balance
120
+ await expect(createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc")).rejects.toThrow(
121
+ MisoApiContractError,
122
+ );
123
+ });
124
+
125
+ test("a u64 sent as a NUMBER is rejected — the precision bug this contract exists to prevent", async () => {
126
+ const { fetch } = stubFetch({ body: { ...balance, balance: 100000000 } });
127
+ const err = await createMisoApiClient({ baseUrl: BASE, fetch })
128
+ .getBalance("0xabc")
129
+ .catch((e: unknown) => e);
130
+ expect(err).toBeInstanceOf(MisoApiContractError);
131
+ });
132
+
133
+ test("the contract error names the field and points at version skew", async () => {
134
+ const { fetch } = stubFetch({ body: { ...balance, balance: "not-a-number" } });
135
+ try {
136
+ await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc");
137
+ throw new Error("should have thrown");
138
+ } catch (e) {
139
+ expect((e as Error).message).toContain("balance");
140
+ expect((e as Error).message).toContain("different versions");
141
+ }
142
+ });
143
+
144
+ test("a valid response parses through to typed data", async () => {
145
+ const { fetch } = stubFetch({ body: balance });
146
+ const result = await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc");
147
+ expect(result).toEqual(balance);
148
+ });
149
+ });
150
+
151
+ describe("READ_CACHE_CLASS", () => {
152
+ test("every wallet-scoped read is private", () => {
153
+ for (const [method, cls] of Object.entries(READ_CACHE_CLASS)) {
154
+ if (method.startsWith("getWallet") || method.startsWith("owns") || method === "getBalance" || method === "getWork") {
155
+ expect(cls).toBe("private");
156
+ }
157
+ }
158
+ });
159
+
160
+ test("reads whose answer can never change are immutable", () => {
161
+ expect(READ_CACHE_CLASS.getRecordAlbum).toBe("immutable");
162
+ expect(READ_CACHE_CLASS.getReceipt).toBe("immutable");
163
+ });
164
+
165
+ test("live sale reads are the sale class", () => {
166
+ expect(READ_CACHE_CLASS.getDiscover).toBe("sale");
167
+ expect(READ_CACHE_CLASS.getPressing).toBe("sale");
168
+ });
169
+ });
170
+
171
+ describe("cache buster", () => {
172
+ test("no `v` is sent until something has been written", async () => {
173
+ const { fetch, calls } = stubFetch({ body: balance });
174
+ await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc");
175
+ expect(calls[0]).not.toContain("v=");
176
+ });
177
+
178
+ test("a supplied version rides on every read as ?v=", async () => {
179
+ const { fetch, calls } = stubFetch({ body: balance });
180
+ const api = createMisoApiClient({ baseUrl: BASE, fetch, version: () => "1699999999" });
181
+ await api.getBalance("0xabc");
182
+ expect(calls[0]).toContain("v=1699999999");
183
+ });
184
+
185
+ test("the version is read per request, so one client can be bumped in place", async () => {
186
+ const { fetch, calls } = stubFetch({ body: balance });
187
+ let v: string | undefined;
188
+ const api = createMisoApiClient({ baseUrl: BASE, fetch, version: () => v });
189
+ await api.getBalance("0xabc");
190
+ v = "42";
191
+ await api.getBalance("0xabc");
192
+ expect(calls[0]).not.toContain("v=");
193
+ expect(calls[1]).toContain("v=42");
194
+ });
195
+
196
+ test("cacheBuster is per-SECOND, so a burst after one write shares an entry", () => {
197
+ // Millisecond granularity would mint a fresh cache entry per read.
198
+ expect(cacheBuster(1_700_000_000_123)).toBe(cacheBuster(1_700_000_000_900));
199
+ expect(cacheBuster(1_700_000_000_000)).not.toBe(cacheBuster(1_700_000_001_000));
200
+ });
201
+ });
package/src/client.ts ADDED
@@ -0,0 +1,272 @@
1
+ // Copyright (c) Miso Labs, Inc.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // The typed client for the Miso read API. One of these per app.
5
+ //
6
+ // const api = createMisoApiClient({ baseUrl: "https://api.testnet.miso.fm" })
7
+ // const shelf = await api.getDiscover()
8
+ //
9
+ // Every method returns a PARSED, validated body. Validation on the client is not
10
+ // paranoia about our own server — it is what turns a silent contract break into a
11
+ // loud one at the boundary that noticed, instead of a `Cannot read property of
12
+ // undefined` three components deep.
13
+ //
14
+ // Reads that can legitimately find nothing (a pressing that was superseded, a
15
+ // party with no pin) return `null` on 404 rather than throwing. Everything else
16
+ // throws `MisoApiError` carrying the server's `{ error: { code, message } }`.
17
+
18
+ import type { z } from "zod";
19
+ import * as s from "./schemas.ts";
20
+ import { queryPolicy, type CacheClass } from "./cache.ts";
21
+ import type {
22
+ ArtistProfile,
23
+ Balance,
24
+ DiscoverShelf,
25
+ DropPreview,
26
+ FeaturedRelease,
27
+ OwnedParty,
28
+ OwnedRecord,
29
+ OwnedWork,
30
+ Ownership,
31
+ PartySummary,
32
+ PressingDetail,
33
+ PurchaseReceipt,
34
+ RecordAlbum,
35
+ ReleaseDetail,
36
+ ReleaseTrackCredits,
37
+ WorkDetail,
38
+ } from "./types.ts";
39
+
40
+ export class MisoApiError extends Error {
41
+ readonly status: number;
42
+ readonly code: string;
43
+
44
+ constructor(status: number, code: string, message: string) {
45
+ super(message);
46
+ this.name = "MisoApiError";
47
+ this.status = status;
48
+ this.code = code;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * The server said something this client cannot describe. Distinct from
54
+ * `MisoApiError` because the fix is different: an API error is an expected
55
+ * outcome to render, a contract error is a version skew to deploy past.
56
+ */
57
+ export class MisoApiContractError extends Error {
58
+ readonly issues: z.core.$ZodIssue[];
59
+
60
+ constructor(path: string, issues: z.core.$ZodIssue[]) {
61
+ const first = issues[0];
62
+ super(
63
+ `Response from ${path} did not match the expected contract` +
64
+ (first ? `: ${first.path.join(".")} — ${first.message}` : "") +
65
+ ". The API and @misofm/api-client are likely different versions.",
66
+ );
67
+ this.name = "MisoApiContractError";
68
+ this.issues = issues;
69
+ }
70
+ }
71
+
72
+ export interface MisoApiClientOptions {
73
+ /** Public API origin, e.g. `https://api.testnet.miso.fm`. */
74
+ baseUrl: string;
75
+ /**
76
+ * Cache buster appended to every read as `?v=`. There is no purge layer — the
77
+ * edge cache is an optimization, not a source of truth — so a surface that has
78
+ * just WRITTEN something and needs to see its own change before the TTL
79
+ * expires supplies a fresh value here (see {@link cacheBuster}).
80
+ *
81
+ * Returns a value rather than taking one so a single long-lived client can be
82
+ * bumped in place. Returning `undefined` (the default) sends no `v` at all,
83
+ * which is what every read should do until something is written.
84
+ */
85
+ version?: () => string | undefined;
86
+ /** Injectable for tests, Workers, and anything with its own fetch. */
87
+ fetch?: typeof globalThis.fetch;
88
+ /**
89
+ * Path the read endpoints are mounted under on `baseUrl`. The gateway routes
90
+ * `/read/*` to the read service, whose own routes are rooted at `/v1`.
91
+ */
92
+ prefix?: string;
93
+ }
94
+
95
+ type QueryValue = string | number | boolean | undefined | null;
96
+
97
+ export function createMisoApiClient(options: MisoApiClientOptions) {
98
+ const base = options.baseUrl.replace(/\/$/, "");
99
+ const prefix = options.prefix ?? "/read/v1";
100
+ const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
101
+
102
+ function url(path: string, query: Record<string, QueryValue> = {}): string {
103
+ const u = new URL(`${base}${prefix}${path}`);
104
+ for (const [k, v] of Object.entries(query)) {
105
+ if (v !== undefined && v !== null && v !== "") u.searchParams.set(k, String(v));
106
+ }
107
+ const v = options.version?.();
108
+ if (v) u.searchParams.set("v", v);
109
+ return u.toString();
110
+ }
111
+
112
+ async function request<T>(
113
+ schema: z.ZodType<T>,
114
+ path: string,
115
+ query: Record<string, QueryValue> = {},
116
+ opts: { nullOn404?: boolean } = {},
117
+ ): Promise<T | null> {
118
+ const target = url(path, query);
119
+ const res = await doFetch(target, { headers: { Accept: "application/json" } });
120
+
121
+ if (res.status === 404 && opts.nullOn404) return null;
122
+
123
+ if (!res.ok) {
124
+ const body = await res.json().catch(() => null);
125
+ const parsed = s.apiErrorSchema.safeParse(body);
126
+ throw parsed.success
127
+ ? new MisoApiError(res.status, parsed.data.error.code, parsed.data.error.message)
128
+ : new MisoApiError(res.status, "unknown", `Request to ${path} failed (${res.status})`);
129
+ }
130
+
131
+ const json = await res.json().catch(() => {
132
+ throw new MisoApiError(res.status, "bad_response", `Response from ${path} was not JSON`);
133
+ });
134
+
135
+ const parsed = schema.safeParse(json);
136
+ if (!parsed.success) throw new MisoApiContractError(path, parsed.error.issues);
137
+ return parsed.data;
138
+ }
139
+
140
+ /** Same as `request`, for endpoints that always return a body. */
141
+ async function required<T>(schema: z.ZodType<T>, path: string, query?: Record<string, QueryValue>): Promise<T> {
142
+ return (await request(schema, path, query)) as T;
143
+ }
144
+
145
+ return {
146
+ // ── Catalog ─────────────────────────────────────────────────────────────
147
+ /** The records currently on sale. */
148
+ getDiscover: (): Promise<DiscoverShelf> => required(s.discoverShelfSchema, "/discover"),
149
+
150
+ /** A pressing and everything its buy page renders. `null` when there is no such pressing. */
151
+ getPressing: (pressingId: string): Promise<PressingDetail | null> =>
152
+ request(s.pressingDetailSchema, `/pressings/${pressingId}`, {}, { nullOn404: true }),
153
+
154
+ /** A release with its cover, credits, and resolved tracklist. */
155
+ getRelease: (releaseId: string): Promise<ReleaseDetail | null> =>
156
+ request(s.releaseDetailSchema, `/releases/${releaseId}`, {}, { nullOn404: true }),
157
+
158
+ /** Per-track credits for a release, keyed by recording id. */
159
+ getReleaseTrackCredits: (releaseId: string): Promise<ReleaseTrackCredits> =>
160
+ required(s.releaseTrackCreditsSchema, `/releases/${releaseId}/credits`),
161
+
162
+ /** A record's parent release. Immutable for the life of the record. */
163
+ getRecordAlbum: (recordId: string): Promise<RecordAlbum | null> =>
164
+ request(s.recordAlbumSchema, `/records/${recordId}/album`, {}, { nullOn404: true }),
165
+
166
+ /** Confirmation preview for a pasted pressing id. `null` when it isn't a pressing. */
167
+ getDropPreview: (dropId: string): Promise<DropPreview | null> =>
168
+ request(s.dropPreviewSchema, `/drops/${dropId}/preview`, {}, { nullOn404: true }),
169
+
170
+ // ── Artist ──────────────────────────────────────────────────────────────
171
+ /** An artist page. `include` adds the owner-editor fields (roles, tags). */
172
+ getArtist: (partyId: string, opts: { include?: readonly ("roles" | "tags")[] } = {}): Promise<ArtistProfile | null> =>
173
+ request(
174
+ s.artistProfileSchema,
175
+ `/artists/${partyId}`,
176
+ { include: opts.include?.join(",") },
177
+ { nullOn404: true },
178
+ ),
179
+
180
+ /** The party's pinned pressing, resolved. `null` when nothing is pinned. */
181
+ getArtistFeatured: (partyId: string): Promise<FeaturedRelease | null> =>
182
+ request(s.featuredReleaseSchema.nullable(), `/artists/${partyId}/featured`) as Promise<FeaturedRelease | null>,
183
+
184
+ /** Name + kind for many parties at once. */
185
+ getArtists: (ids: readonly string[]): Promise<PartySummary[]> =>
186
+ ids.length === 0
187
+ ? Promise.resolve([])
188
+ : required(s.partySummariesSchema, "/artists", { ids: ids.join(",") }),
189
+
190
+ // ── Wallet-scoped ───────────────────────────────────────────────────────
191
+ /** The records this wallet holds. */
192
+ getWalletRecords: (address: string): Promise<OwnedRecord[]> =>
193
+ required(s.ownedRecordsSchema, `/wallets/${address}/records`),
194
+
195
+ /** The parties this wallet administers. */
196
+ getWalletParties: (address: string): Promise<OwnedParty[]> =>
197
+ required(s.ownedPartiesSchema, `/wallets/${address}/parties`),
198
+
199
+ /** The works this wallet administers, keyed by admin cap. */
200
+ getWalletWorks: (address: string): Promise<OwnedWork[]> =>
201
+ required(s.ownedWorksSchema, `/wallets/${address}/works`),
202
+
203
+ /** One administered work, by its admin cap id. */
204
+ getWork: (capId: string): Promise<WorkDetail | null> =>
205
+ request(s.workDetailSchema, `/works/${capId}`, {}, { nullOn404: true }),
206
+
207
+ /** Spendable balance. Defaults to the app's dollar when `coinType` is omitted. */
208
+ getBalance: (address: string, coinType?: string): Promise<Balance> =>
209
+ required(s.balanceSchema, `/wallets/${address}/balance`, { coinType }),
210
+
211
+ /** Whether the wallet holds this party's admin cap. Carries the cap id. */
212
+ ownsParty: (address: string, partyId: string): Promise<Ownership> =>
213
+ required(s.ownershipSchema, `/wallets/${address}/owns`, { party: partyId }),
214
+
215
+ /** Whether the wallet owns this record. */
216
+ ownsRecord: (address: string, recordId: string): Promise<Ownership> =>
217
+ required(s.ownershipSchema, `/wallets/${address}/owns`, { record: recordId }),
218
+
219
+ // ── Receipts ────────────────────────────────────────────────────────────
220
+ /** What one record sale was, re-derived from its transaction. */
221
+ getReceipt: (pressingId: string, txDigest: string): Promise<PurchaseReceipt | null> =>
222
+ request(s.purchaseReceiptSchema, `/receipts/${pressingId}/${txDigest}`, {}, { nullOn404: true }),
223
+ };
224
+ }
225
+
226
+ export type MisoApiClient = ReturnType<typeof createMisoApiClient>;
227
+
228
+ /**
229
+ * A cache-buster value for {@link MisoApiClientOptions.version}.
230
+ *
231
+ * Bump this after a write lands on-chain and the writer's next read mints a
232
+ * fresh cache entry instead of being served the pre-write one. It is deliberately
233
+ * coarse — seconds, not milliseconds — so a burst of reads right after one write
234
+ * shares a single entry rather than minting one each.
235
+ *
236
+ * Request-side revalidation is NOT an alternative: measured against the deployed
237
+ * edge on 2026-08-09, both `?fresh=1` and a raw `Cache-Control: no-cache`
238
+ * request header returned cache hits (~50ms) against a ~350ms origin fill.
239
+ * Workers Cache does not honour them, so the buster has to be in the key.
240
+ */
241
+ export function cacheBuster(nowMs: number = Date.now()): string {
242
+ return String(Math.floor(nowMs / 1000));
243
+ }
244
+
245
+ /**
246
+ * The cache class each read belongs to, so a caller can align its own query
247
+ * options with the edge's TTL: `useQuery({ ...queryPolicy(READ_CACHE_CLASS.getPressing), … })`.
248
+ *
249
+ * `getRelease` is absent on purpose — a release's class depends on whether it is
250
+ * published, which is a property of the response, not the route. Callers derive
251
+ * it with `workCacheClass(release.state)`.
252
+ */
253
+ export const READ_CACHE_CLASS = {
254
+ getDiscover: "sale",
255
+ getPressing: "sale",
256
+ getDropPreview: "sale",
257
+ getReleaseTrackCredits: "published",
258
+ getRecordAlbum: "immutable",
259
+ getReceipt: "immutable",
260
+ getArtist: "artist",
261
+ getArtistFeatured: "artist",
262
+ getArtists: "artist",
263
+ getWalletRecords: "private",
264
+ getWalletParties: "private",
265
+ getWalletWorks: "private",
266
+ getWork: "private",
267
+ getBalance: "private",
268
+ ownsParty: "private",
269
+ ownsRecord: "private",
270
+ } as const satisfies Record<string, CacheClass>;
271
+
272
+ export { queryPolicy };
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ // Copyright (c) Miso Labs, Inc.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // @misofm/api-client — the typed client and response contract for the
5
+ // Miso read API.
6
+ //
7
+ // This package is what every Miso frontend reads through: the PWA, the CLI,
8
+ // agents. It carries no chain code and no Sui dependency — a browser importing it
9
+ // gets zod and a fetch wrapper, not a blockchain SDK.
10
+ //
11
+ // import { createMisoApiClient } from "@misofm/api-client";
12
+ // const api = createMisoApiClient({ baseUrl: "https://api.testnet.miso.fm" });
13
+
14
+ export { createMisoApiClient, cacheBuster, MisoApiError, MisoApiContractError, READ_CACHE_CLASS, queryPolicy } from "./client.ts";
15
+ export type { MisoApiClient, MisoApiClientOptions } from "./client.ts";
16
+
17
+ export { CACHE_POLICIES, cacheControl, workCacheClass } from "./cache.ts";
18
+ export type { CacheClass, CachePolicy } from "./cache.ts";
19
+
20
+ export * as schemas from "./schemas.ts";
21
+ export type * from "./types.ts";
22
+
23
+ // Card checkout lives in miso-platform-service, not the read service, but it is
24
+ // the same API to a caller — one package, one base URL, one error type.
25
+ export * from "./checkout.ts";
package/src/schemas.ts ADDED
@@ -0,0 +1,356 @@
1
+ // Copyright (c) Miso Labs, Inc.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // The response contract for every Miso read. This file is the SOURCE OF TRUTH:
5
+ //
6
+ // · @misofm/api-client infers its types from it
7
+ // · miso-read-service generates its OpenAPI document from it
8
+ // · a contract test in that service parses every handler's real output through
9
+ // it, so a change in @misonetwork/sdk that these schemas don't describe fails CI
10
+ // rather than reaching a client
11
+ //
12
+ // Schemas, not hand-written interfaces, precisely so that third clause is
13
+ // possible. Agents, the CLI, and any future surface read the same OpenAPI.
14
+ //
15
+ // SCALARS: every u64/u128 is a DECIMAL STRING (`u64Schema`), never a number.
16
+ // Prices, supply counts, and balances routinely exceed 2^53, and JSON has no
17
+ // integer type that holds them. Millisecond timestamps stay numbers — they are
18
+ // inside Number.MAX_SAFE_INTEGER and callers want to pass them to `new Date()`.
19
+
20
+ import { z } from "zod";
21
+
22
+ /** A u64/u128 in base units, as a decimal string. See the file header. */
23
+ export const u64Schema = z
24
+ .string()
25
+ .regex(/^\d+$/, "expected a decimal integer string");
26
+
27
+ /** A 0x-prefixed 32-byte Sui object id or address, in canonical form. */
28
+ export const suiIdSchema = z
29
+ .string()
30
+ .regex(/^0x[0-9a-fA-F]{1,64}$/, "expected a Sui object id");
31
+
32
+ // ── Shared ───────────────────────────────────────────────────────────────────
33
+
34
+ export const workStateSchema = z.union([
35
+ z.object({ type: z.literal("Initialized") }),
36
+ z.object({ type: z.literal("Published"), timestampMs: z.number().int() }),
37
+ ]);
38
+
39
+ export const coverImageSchema = z.object({
40
+ kind: z.enum(["blob", "quiltPatch"]),
41
+ /** Aggregator URL — the only field a renderer needs. */
42
+ url: z.string().url(),
43
+ });
44
+
45
+ export const coverSchema = z.object({
46
+ still: coverImageSchema,
47
+ animated: coverImageSchema.nullable(),
48
+ });
49
+
50
+ export const creditSchema = z.object({
51
+ partyId: suiIdSchema,
52
+ displayName: z.string(),
53
+ roles: z.array(z.string()),
54
+ });
55
+
56
+ export const trackViewSchema = z.object({
57
+ /** Display number — "1", or "1.2" (disc.track) on a multi-disc set. */
58
+ no: z.string(),
59
+ title: z.string(),
60
+ recordingId: suiIdSchema,
61
+ /** This track's share of the release's revenue, in basis points. */
62
+ splitBps: z.number().int().min(0).max(10_000),
63
+ disc: z.number().int().min(1),
64
+ });
65
+
66
+ // ── Catalog ──────────────────────────────────────────────────────────────────
67
+
68
+ export const releaseDetailSchema = z.object({
69
+ id: suiIdSchema,
70
+ title: z.string(),
71
+ subtitle: z.string().nullable(),
72
+ state: workStateSchema,
73
+ publishedAtMs: z.number().int().nullable(),
74
+ cover: coverSchema.nullable(),
75
+ credits: z.array(creditSchema),
76
+ /** The release's primary artists in chain order — its artist line. */
77
+ primaryArtists: z.array(z.string()),
78
+ discCount: z.number().int().min(0),
79
+ tracks: z.array(trackViewSchema),
80
+ });
81
+
82
+ export const priceSchema = z.object({
83
+ /** `fixed` — pay exactly this. `floor` — pay at least this. */
84
+ kind: z.enum(["fixed", "floor"]),
85
+ amount: u64Schema,
86
+ });
87
+
88
+ export const currencySchema = z.object({
89
+ type: z.string().nullable(),
90
+ symbol: z.string(),
91
+ decimals: z.number().int().min(0).max(18),
92
+ });
93
+
94
+ export const pressingViewSchema = z.object({
95
+ id: suiIdSchema,
96
+ releaseId: suiIdSchema,
97
+ edition: z.number().int().min(0),
98
+ price: priceSchema,
99
+ currency: currencySchema,
100
+ quantitySold: u64Schema,
101
+ /** Null = uncapped (an open edition). */
102
+ maxSupply: u64Schema.nullable(),
103
+ startTimestampMs: z.number().int(),
104
+ /** Null = evergreen. */
105
+ endTimestampMs: z.number().int().nullable(),
106
+ soldOut: z.boolean(),
107
+ });
108
+
109
+ export const pressingDetailSchema = z.object({
110
+ pressing: pressingViewSchema,
111
+ release: releaseDetailSchema,
112
+ });
113
+
114
+ export const discoverItemSchema = z.object({
115
+ pressing: pressingViewSchema,
116
+ releaseId: suiIdSchema,
117
+ title: z.string(),
118
+ /** Primary artists joined for display. Empty when no credits are set. */
119
+ artist: z.string(),
120
+ coverUrl: z.string().url().nullable(),
121
+ });
122
+
123
+ export const discoverShelfSchema = z.array(discoverItemSchema);
124
+
125
+ export const recordAlbumSchema = z.object({
126
+ recordId: suiIdSchema,
127
+ releaseId: suiIdSchema.nullable(),
128
+ });
129
+
130
+ export const dropPreviewSchema = z.object({
131
+ pressingId: suiIdSchema,
132
+ currency: currencySchema,
133
+ title: z.string(),
134
+ subtitle: z.string().nullable(),
135
+ coverUrl: z.string().url().nullable(),
136
+ price: priceSchema,
137
+ trackCount: z.number().int().min(0),
138
+ });
139
+
140
+ /** A recording's work-role credits and recording billing positions. */
141
+ export const recordingCreditsSchema = z.object({
142
+ credits: z.array(creditSchema),
143
+ primaryArtistIds: z.array(suiIdSchema),
144
+ featuredArtistIds: z.array(suiIdSchema),
145
+ });
146
+
147
+ /** Per-track credits for a release. Composition writing and recording
148
+ * performance/production credits remain separate. */
149
+ export const trackCreditsSchema = z.union([
150
+ z.object({
151
+ compositionCredits: z.array(creditSchema),
152
+ recordingCredits: recordingCreditsSchema,
153
+ }),
154
+ // Keep already-deployed recording-only responses usable while the read service
155
+ // rolls out the composition-credit projection.
156
+ recordingCreditsSchema.transform((recordingCredits) => ({
157
+ compositionCredits: [],
158
+ recordingCredits,
159
+ })),
160
+ ]);
161
+
162
+ /** Per-track work credits for a release, keyed by recording id. */
163
+ export const releaseTrackCreditsSchema = z.object({
164
+ tracks: z.record(suiIdSchema, trackCreditsSchema),
165
+ });
166
+
167
+ // ── Artist ───────────────────────────────────────────────────────────────────
168
+
169
+ export const partyMemberSchema = z.object({
170
+ id: suiIdSchema,
171
+ name: z.string(),
172
+ });
173
+
174
+ /**
175
+ * Every external platform the party link extensions know how to build a URL for,
176
+ * spanning social, music, and professional payloads. Enumerated rather than left
177
+ * as `string` so a renderer's icon/label switch is exhaustive at compile time —
178
+ * adding a platform on-chain should break the UI that has no icon for it.
179
+ */
180
+ export const platformKeySchema = z.enum([
181
+ // Social (party_social)
182
+ "x",
183
+ "instagram",
184
+ "threads",
185
+ "tiktok",
186
+ "youtube",
187
+ "discord",
188
+ "telegram",
189
+ "reddit",
190
+ "twitch",
191
+ "facebook",
192
+ // Music (party_music)
193
+ "spotify",
194
+ "bandcamp",
195
+ "soundcloud",
196
+ "appleMusic",
197
+ "deezer",
198
+ "tidal",
199
+ "amazonMusic",
200
+ "audiomack",
201
+ // Professional / industry (party_pro_link)
202
+ "website",
203
+ "bookingPage",
204
+ "managementPage",
205
+ "publisherPage",
206
+ "labelPage",
207
+ "epk",
208
+ "patreon",
209
+ "substack",
210
+ "kofi",
211
+ ]);
212
+
213
+ export const partyLinkSchema = z.object({
214
+ platform: platformKeySchema,
215
+ /** The platform-native identifier stored on-chain (handle / id / subdomain / URL). */
216
+ value: z.string(),
217
+ /** The public profile URL, rebuilt client-side from `value`. */
218
+ url: z.string(),
219
+ });
220
+
221
+ export const partyCtaSchema = z.object({ label: z.string(), url: z.string() });
222
+
223
+ export const artistProfileSchema = z.object({
224
+ id: suiIdSchema,
225
+ kind: z.enum(["individual", "group"]),
226
+ name: z.string(),
227
+ createdAtMs: z.number().int(),
228
+ bioShort: z.string().nullable(),
229
+ bioLong: z.string().nullable(),
230
+ country: z.string().nullable(),
231
+ languages: z.array(z.string()),
232
+ /** Display names, already humanized from the on-chain HIP_HOP form. */
233
+ genres: z.array(z.string()),
234
+ links: z.array(partyLinkSchema),
235
+ ctas: z.array(partyCtaSchema),
236
+ members: z.array(partyMemberSchema),
237
+ /** Present only when requested via `include` — the owner-editor fields. */
238
+ roles: z.array(z.string()).optional(),
239
+ tags: z.array(z.string()).optional(),
240
+ avatarUrl: z.string().url(),
241
+ });
242
+
243
+ export const featuredReleaseSchema = z.object({
244
+ pressingId: suiIdSchema,
245
+ releaseId: suiIdSchema,
246
+ title: z.string(),
247
+ artist: z.string(),
248
+ coverUrl: z.string().url().nullable(),
249
+ });
250
+
251
+ export const partySummarySchema = z.object({
252
+ id: suiIdSchema,
253
+ name: z.string(),
254
+ kind: z.enum(["individual", "group"]),
255
+ });
256
+
257
+ export const partySummariesSchema = z.array(partySummarySchema);
258
+
259
+ // ── Wallet-scoped ────────────────────────────────────────────────────────────
260
+
261
+ export const ownedRecordSchema = z.object({
262
+ id: suiIdSchema,
263
+ type: z.string(),
264
+ releaseId: suiIdSchema.nullable(),
265
+ /** This copy's number in its run. */
266
+ number: z.number().int().nullable(),
267
+ });
268
+
269
+ export const ownedRecordsSchema = z.array(ownedRecordSchema);
270
+
271
+ export const ownedPartySchema = z.object({
272
+ partyId: suiIdSchema,
273
+ capId: suiIdSchema,
274
+ name: z.string(),
275
+ kind: z.enum(["individual", "group"]),
276
+ });
277
+
278
+ export const ownedPartiesSchema = z.array(ownedPartySchema);
279
+
280
+ export const workKindSchema = z.enum(["composition", "recording", "release"]);
281
+
282
+ export const ownedWorkSchema = z.object({
283
+ /** The ADMIN CAP object id — the catalog's routing key. */
284
+ capId: suiIdSchema,
285
+ kind: workKindSchema,
286
+ workId: suiIdSchema,
287
+ title: z.string(),
288
+ state: z.string(),
289
+ });
290
+
291
+ export const ownedWorksSchema = z.array(ownedWorkSchema);
292
+
293
+ export const workDetailSchema = ownedWorkSchema.extend({
294
+ subtitle: z.string().optional(),
295
+ royaltyRateBps: z.number().int().min(0).max(10_000).optional(),
296
+ shareType: z.string().optional(),
297
+ discCount: z.number().int().min(0).optional(),
298
+ trackCount: z.number().int().min(0).optional(),
299
+ });
300
+
301
+ export const balanceSchema = z.object({
302
+ address: suiIdSchema,
303
+ coinType: z.string(),
304
+ /** Base units. Totals coin objects AND the address balance. */
305
+ balance: u64Schema,
306
+ decimals: z.number().int().min(0).max(18),
307
+ });
308
+
309
+ export const ownershipSchema = z.object({
310
+ address: suiIdSchema,
311
+ objectId: suiIdSchema,
312
+ isOwner: z.boolean(),
313
+ /** Party checks only — the derived PartyAdminCap id owner-gated writes need. */
314
+ capId: suiIdSchema.optional(),
315
+ });
316
+
317
+ // ── Receipts ─────────────────────────────────────────────────────────────────
318
+
319
+ export const recordSaleSchema = z.object({
320
+ dropId: z.string(),
321
+ releaseId: z.string(),
322
+ edition: z.number().int().min(0),
323
+ recordId: suiIdSchema,
324
+ number: u64Schema,
325
+ paid: u64Schema,
326
+ buyer: z.string(),
327
+ });
328
+
329
+ export const trackRoyaltySchema = z.object({
330
+ no: z.string(),
331
+ title: z.string(),
332
+ recordingId: suiIdSchema,
333
+ splitBps: z.number().int().min(0).max(10_000),
334
+ amount: u64Schema,
335
+ /** Both null when the composition's royalty rate could not be resolved. */
336
+ composition: u64Schema.nullable(),
337
+ recording: u64Schema.nullable(),
338
+ });
339
+
340
+ export const purchaseReceiptSchema = z.object({
341
+ sale: recordSaleSchema,
342
+ detail: pressingDetailSchema,
343
+ /** The drop's list price — differs from `sale.paid` on a floor-price overpay. */
344
+ price: u64Schema,
345
+ tracks: z.array(trackRoyaltySchema),
346
+ });
347
+
348
+ // ── Errors ───────────────────────────────────────────────────────────────────
349
+
350
+ /** The envelope every non-2xx carries, matching miso-api's `apiError`. */
351
+ export const apiErrorSchema = z.object({
352
+ error: z.object({
353
+ code: z.string(),
354
+ message: z.string(),
355
+ }),
356
+ });
package/src/types.ts ADDED
@@ -0,0 +1,48 @@
1
+ // Copyright (c) Miso Labs, Inc.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // Types inferred from ./schemas.ts. Nothing is hand-written here on purpose — a
5
+ // type and its validator that can disagree will eventually disagree.
6
+
7
+ import type { z } from "zod";
8
+ import type * as s from "./schemas.ts";
9
+
10
+ export type WorkState = z.infer<typeof s.workStateSchema>;
11
+ export type CoverImage = z.infer<typeof s.coverImageSchema>;
12
+ export type Cover = z.infer<typeof s.coverSchema>;
13
+ export type Credit = z.infer<typeof s.creditSchema>;
14
+ export type TrackView = z.infer<typeof s.trackViewSchema>;
15
+
16
+ export type ReleaseDetail = z.infer<typeof s.releaseDetailSchema>;
17
+ export type TrackCredits = z.infer<typeof s.trackCreditsSchema>;
18
+ export type ReleaseTrackCredits = z.infer<typeof s.releaseTrackCreditsSchema>;
19
+ export type Price = z.infer<typeof s.priceSchema>;
20
+ export type Currency = z.infer<typeof s.currencySchema>;
21
+ export type PressingView = z.infer<typeof s.pressingViewSchema>;
22
+ export type PressingDetail = z.infer<typeof s.pressingDetailSchema>;
23
+ export type DiscoverItem = z.infer<typeof s.discoverItemSchema>;
24
+ export type DiscoverShelf = z.infer<typeof s.discoverShelfSchema>;
25
+ export type RecordAlbum = z.infer<typeof s.recordAlbumSchema>;
26
+ export type DropPreview = z.infer<typeof s.dropPreviewSchema>;
27
+
28
+ export type PartyMember = z.infer<typeof s.partyMemberSchema>;
29
+ export type PlatformKey = z.infer<typeof s.platformKeySchema>;
30
+ export type PartyLink = z.infer<typeof s.partyLinkSchema>;
31
+ export type PartyCta = z.infer<typeof s.partyCtaSchema>;
32
+ export type ArtistProfile = z.infer<typeof s.artistProfileSchema>;
33
+ export type FeaturedRelease = z.infer<typeof s.featuredReleaseSchema>;
34
+ export type PartySummary = z.infer<typeof s.partySummarySchema>;
35
+
36
+ export type OwnedRecord = z.infer<typeof s.ownedRecordSchema>;
37
+ export type OwnedParty = z.infer<typeof s.ownedPartySchema>;
38
+ export type WorkKind = z.infer<typeof s.workKindSchema>;
39
+ export type OwnedWork = z.infer<typeof s.ownedWorkSchema>;
40
+ export type WorkDetail = z.infer<typeof s.workDetailSchema>;
41
+ export type Balance = z.infer<typeof s.balanceSchema>;
42
+ export type Ownership = z.infer<typeof s.ownershipSchema>;
43
+
44
+ export type RecordSale = z.infer<typeof s.recordSaleSchema>;
45
+ export type TrackRoyalty = z.infer<typeof s.trackRoyaltySchema>;
46
+ export type PurchaseReceipt = z.infer<typeof s.purchaseReceiptSchema>;
47
+
48
+ export type ApiErrorBody = z.infer<typeof s.apiErrorSchema>;