@misofm/api-client 0.1.1 → 0.2.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 +2 -2
- package/package.json +1 -1
- package/src/client.test.ts +102 -28
- package/src/client.ts +86 -33
- package/src/index.ts +1 -5
- package/src/schemas.ts +70 -38
- package/src/types.ts +0 -1
- package/src/checkout.ts +0 -146
package/README.md
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
Typed client, schemas, and response contracts for the Miso API read layer.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
|
-
import {
|
|
6
|
+
import { createMisoApiClient } from "@misofm/api-client";
|
|
7
7
|
|
|
8
|
-
const client =
|
|
8
|
+
const client = createMisoApiClient({ baseUrl: "https://api.testnet.miso.fm" });
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
The package is maintained in the [`misofm/api`](https://github.com/misofm/api)
|
package/package.json
CHANGED
package/src/client.test.ts
CHANGED
|
@@ -2,17 +2,26 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
import { describe, expect, test } from "bun:test";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
cacheBuster,
|
|
7
|
+
createMisoApiClient,
|
|
8
|
+
MisoApiContractError,
|
|
9
|
+
MisoApiError,
|
|
10
|
+
READ_CACHE_CLASS,
|
|
11
|
+
} from "./client.ts";
|
|
6
12
|
|
|
7
13
|
/** A fetch that records the URL it was called with and replays a canned response. */
|
|
8
14
|
function stubFetch(response: { status?: number; body?: unknown }) {
|
|
9
15
|
const calls: string[] = [];
|
|
10
16
|
const fetch = (async (input: string | URL) => {
|
|
11
17
|
calls.push(String(input));
|
|
12
|
-
return new Response(
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
return new Response(
|
|
19
|
+
response.body === undefined ? "" : JSON.stringify(response.body),
|
|
20
|
+
{
|
|
21
|
+
status: response.status ?? 200,
|
|
22
|
+
headers: { "Content-Type": "application/json" },
|
|
23
|
+
},
|
|
24
|
+
);
|
|
16
25
|
}) as unknown as typeof globalThis.fetch;
|
|
17
26
|
return { fetch, calls };
|
|
18
27
|
}
|
|
@@ -35,56 +44,98 @@ describe("URL construction", () => {
|
|
|
35
44
|
|
|
36
45
|
test("tolerates a trailing slash on the base URL", async () => {
|
|
37
46
|
const { fetch, calls } = stubFetch({ body: balance });
|
|
38
|
-
await createMisoApiClient({ baseUrl: `${BASE}/`, fetch }).getBalance(
|
|
47
|
+
await createMisoApiClient({ baseUrl: `${BASE}/`, fetch }).getBalance(
|
|
48
|
+
"0xabc",
|
|
49
|
+
);
|
|
39
50
|
expect(calls[0]).toBe(`${BASE}/read/v1/wallets/0xabc/balance`);
|
|
40
51
|
});
|
|
41
52
|
|
|
42
53
|
test("omits empty query params rather than sending them blank", async () => {
|
|
43
54
|
const { fetch, calls } = stubFetch({ body: balance });
|
|
44
|
-
await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance(
|
|
55
|
+
await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance(
|
|
56
|
+
"0xabc",
|
|
57
|
+
undefined,
|
|
58
|
+
);
|
|
45
59
|
expect(calls[0]).not.toContain("coinType");
|
|
46
60
|
});
|
|
47
61
|
|
|
48
62
|
test("sends an explicit coin type when given one", async () => {
|
|
49
63
|
const { fetch, calls } = stubFetch({ body: balance });
|
|
50
|
-
await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance(
|
|
64
|
+
await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance(
|
|
65
|
+
"0xabc",
|
|
66
|
+
"0x2::sui::SUI",
|
|
67
|
+
);
|
|
51
68
|
expect(calls[0]).toContain("coinType=0x2%3A%3Asui%3A%3ASUI");
|
|
52
69
|
});
|
|
53
70
|
|
|
54
71
|
test("joins the artist include list into one param", async () => {
|
|
55
72
|
const { fetch, calls } = stubFetch({ status: 404 });
|
|
56
|
-
await createMisoApiClient({ baseUrl: BASE, fetch }).getArtist("0xp", {
|
|
57
|
-
|
|
73
|
+
await createMisoApiClient({ baseUrl: BASE, fetch }).getArtist("0xp", {
|
|
74
|
+
include: ["roles", "tags", "featured"],
|
|
75
|
+
});
|
|
76
|
+
expect(calls[0]).toContain("include=roles%2Ctags%2Cfeatured");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("joins catalog relationship expansions into one param", async () => {
|
|
80
|
+
const { fetch, calls } = stubFetch({ status: 404 });
|
|
81
|
+
const client = createMisoApiClient({ baseUrl: BASE, fetch });
|
|
82
|
+
await client.getPressing("0xp", { include: ["trackCredits"] });
|
|
83
|
+
await client.getRelease("0xl", { include: ["trackCredits"] });
|
|
84
|
+
await client.getRecordAlbum("0xr", {
|
|
85
|
+
include: ["release", "trackCredits"],
|
|
86
|
+
});
|
|
87
|
+
expect(calls[0]).toContain("include=trackCredits");
|
|
88
|
+
expect(calls[1]).toContain("include=trackCredits");
|
|
89
|
+
expect(calls[2]).toContain("include=release%2CtrackCredits");
|
|
58
90
|
});
|
|
59
91
|
|
|
60
92
|
test("a custom prefix is honored (self-hosted / direct-to-service)", async () => {
|
|
61
93
|
const { fetch, calls } = stubFetch({ body: balance });
|
|
62
|
-
await createMisoApiClient({
|
|
94
|
+
await createMisoApiClient({
|
|
95
|
+
baseUrl: BASE,
|
|
96
|
+
fetch,
|
|
97
|
+
prefix: "/v1",
|
|
98
|
+
}).getBalance("0xabc");
|
|
63
99
|
expect(calls[0]).toBe(`${BASE}/v1/wallets/0xabc/balance`);
|
|
64
100
|
});
|
|
65
101
|
});
|
|
66
102
|
|
|
67
103
|
describe("not-found handling", () => {
|
|
68
104
|
test("a superseded pressing is null, not an error", async () => {
|
|
69
|
-
const { fetch } = stubFetch({
|
|
70
|
-
|
|
105
|
+
const { fetch } = stubFetch({
|
|
106
|
+
status: 404,
|
|
107
|
+
body: { error: { code: "not_found", message: "gone" } },
|
|
108
|
+
});
|
|
109
|
+
expect(
|
|
110
|
+
await createMisoApiClient({ baseUrl: BASE, fetch }).getPressing("0xdead"),
|
|
111
|
+
).toBeNull();
|
|
71
112
|
});
|
|
72
113
|
|
|
73
114
|
test("an unknown artist is null", async () => {
|
|
74
|
-
const { fetch } = stubFetch({
|
|
75
|
-
|
|
115
|
+
const { fetch } = stubFetch({
|
|
116
|
+
status: 404,
|
|
117
|
+
body: { error: { code: "not_found", message: "gone" } },
|
|
118
|
+
});
|
|
119
|
+
expect(
|
|
120
|
+
await createMisoApiClient({ baseUrl: BASE, fetch }).getArtist("0xdead"),
|
|
121
|
+
).toBeNull();
|
|
76
122
|
});
|
|
77
123
|
|
|
78
124
|
test("an empty id list short-circuits without a request", async () => {
|
|
79
125
|
const { fetch, calls } = stubFetch({ body: [] });
|
|
80
|
-
expect(
|
|
126
|
+
expect(
|
|
127
|
+
await createMisoApiClient({ baseUrl: BASE, fetch }).getArtists([]),
|
|
128
|
+
).toEqual([]);
|
|
81
129
|
expect(calls).toHaveLength(0);
|
|
82
130
|
});
|
|
83
131
|
});
|
|
84
132
|
|
|
85
133
|
describe("error handling", () => {
|
|
86
134
|
test("surfaces the server's error code and message", async () => {
|
|
87
|
-
const { fetch } = stubFetch({
|
|
135
|
+
const { fetch } = stubFetch({
|
|
136
|
+
status: 429,
|
|
137
|
+
body: { error: { code: "rate-limited", message: "Too many requests." } },
|
|
138
|
+
});
|
|
88
139
|
const api = createMisoApiClient({ baseUrl: BASE, fetch });
|
|
89
140
|
await expect(api.getDiscover()).rejects.toThrow(MisoApiError);
|
|
90
141
|
try {
|
|
@@ -109,17 +160,24 @@ describe("error handling", () => {
|
|
|
109
160
|
});
|
|
110
161
|
|
|
111
162
|
test("a 404 on an endpoint that must return a body is an error, not null", async () => {
|
|
112
|
-
const { fetch } = stubFetch({
|
|
113
|
-
|
|
163
|
+
const { fetch } = stubFetch({
|
|
164
|
+
status: 404,
|
|
165
|
+
body: { error: { code: "not_found", message: "gone" } },
|
|
166
|
+
});
|
|
167
|
+
await expect(
|
|
168
|
+
createMisoApiClient({ baseUrl: BASE, fetch }).getWalletRecords("0xabc"),
|
|
169
|
+
).rejects.toThrow(MisoApiError);
|
|
114
170
|
});
|
|
115
171
|
});
|
|
116
172
|
|
|
117
173
|
describe("contract validation", () => {
|
|
118
174
|
test("a response missing a required field fails loudly at the boundary", async () => {
|
|
119
|
-
const { fetch } = stubFetch({
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
175
|
+
const { fetch } = stubFetch({
|
|
176
|
+
body: { address: "0xabc", coinType: "0x2::sui::SUI" },
|
|
177
|
+
}); // no balance
|
|
178
|
+
await expect(
|
|
179
|
+
createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc"),
|
|
180
|
+
).rejects.toThrow(MisoApiContractError);
|
|
123
181
|
});
|
|
124
182
|
|
|
125
183
|
test("a u64 sent as a NUMBER is rejected — the precision bug this contract exists to prevent", async () => {
|
|
@@ -131,7 +189,9 @@ describe("contract validation", () => {
|
|
|
131
189
|
});
|
|
132
190
|
|
|
133
191
|
test("the contract error names the field and points at version skew", async () => {
|
|
134
|
-
const { fetch } = stubFetch({
|
|
192
|
+
const { fetch } = stubFetch({
|
|
193
|
+
body: { ...balance, balance: "not-a-number" },
|
|
194
|
+
});
|
|
135
195
|
try {
|
|
136
196
|
await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc");
|
|
137
197
|
throw new Error("should have thrown");
|
|
@@ -143,7 +203,10 @@ describe("contract validation", () => {
|
|
|
143
203
|
|
|
144
204
|
test("a valid response parses through to typed data", async () => {
|
|
145
205
|
const { fetch } = stubFetch({ body: balance });
|
|
146
|
-
const result = await createMisoApiClient({
|
|
206
|
+
const result = await createMisoApiClient({
|
|
207
|
+
baseUrl: BASE,
|
|
208
|
+
fetch,
|
|
209
|
+
}).getBalance("0xabc");
|
|
147
210
|
expect(result).toEqual(balance);
|
|
148
211
|
});
|
|
149
212
|
});
|
|
@@ -151,7 +214,12 @@ describe("contract validation", () => {
|
|
|
151
214
|
describe("READ_CACHE_CLASS", () => {
|
|
152
215
|
test("every wallet-scoped read is private", () => {
|
|
153
216
|
for (const [method, cls] of Object.entries(READ_CACHE_CLASS)) {
|
|
154
|
-
if (
|
|
217
|
+
if (
|
|
218
|
+
method.startsWith("getWallet") ||
|
|
219
|
+
method.startsWith("owns") ||
|
|
220
|
+
method === "getBalance" ||
|
|
221
|
+
method === "getWork"
|
|
222
|
+
) {
|
|
155
223
|
expect(cls).toBe("private");
|
|
156
224
|
}
|
|
157
225
|
}
|
|
@@ -177,7 +245,11 @@ describe("cache buster", () => {
|
|
|
177
245
|
|
|
178
246
|
test("a supplied version rides on every read as ?v=", async () => {
|
|
179
247
|
const { fetch, calls } = stubFetch({ body: balance });
|
|
180
|
-
const api = createMisoApiClient({
|
|
248
|
+
const api = createMisoApiClient({
|
|
249
|
+
baseUrl: BASE,
|
|
250
|
+
fetch,
|
|
251
|
+
version: () => "1699999999",
|
|
252
|
+
});
|
|
181
253
|
await api.getBalance("0xabc");
|
|
182
254
|
expect(calls[0]).toContain("v=1699999999");
|
|
183
255
|
});
|
|
@@ -196,6 +268,8 @@ describe("cache buster", () => {
|
|
|
196
268
|
test("cacheBuster is per-SECOND, so a burst after one write shares an entry", () => {
|
|
197
269
|
// Millisecond granularity would mint a fresh cache entry per read.
|
|
198
270
|
expect(cacheBuster(1_700_000_000_123)).toBe(cacheBuster(1_700_000_000_900));
|
|
199
|
-
expect(cacheBuster(1_700_000_000_000)).not.toBe(
|
|
271
|
+
expect(cacheBuster(1_700_000_000_000)).not.toBe(
|
|
272
|
+
cacheBuster(1_700_000_001_000),
|
|
273
|
+
);
|
|
200
274
|
});
|
|
201
275
|
});
|
package/src/client.ts
CHANGED
|
@@ -23,7 +23,6 @@ import type {
|
|
|
23
23
|
Balance,
|
|
24
24
|
DiscoverShelf,
|
|
25
25
|
DropPreview,
|
|
26
|
-
FeaturedRelease,
|
|
27
26
|
OwnedParty,
|
|
28
27
|
OwnedRecord,
|
|
29
28
|
OwnedWork,
|
|
@@ -33,7 +32,6 @@ import type {
|
|
|
33
32
|
PurchaseReceipt,
|
|
34
33
|
RecordAlbum,
|
|
35
34
|
ReleaseDetail,
|
|
36
|
-
ReleaseTrackCredits,
|
|
37
35
|
WorkDetail,
|
|
38
36
|
} from "./types.ts";
|
|
39
37
|
|
|
@@ -102,7 +100,8 @@ export function createMisoApiClient(options: MisoApiClientOptions) {
|
|
|
102
100
|
function url(path: string, query: Record<string, QueryValue> = {}): string {
|
|
103
101
|
const u = new URL(`${base}${prefix}${path}`);
|
|
104
102
|
for (const [k, v] of Object.entries(query)) {
|
|
105
|
-
if (v !== undefined && v !== null && v !== "")
|
|
103
|
+
if (v !== undefined && v !== null && v !== "")
|
|
104
|
+
u.searchParams.set(k, String(v));
|
|
106
105
|
}
|
|
107
106
|
const v = options.version?.();
|
|
108
107
|
if (v) u.searchParams.set("v", v);
|
|
@@ -116,7 +115,9 @@ export function createMisoApiClient(options: MisoApiClientOptions) {
|
|
|
116
115
|
opts: { nullOn404?: boolean } = {},
|
|
117
116
|
): Promise<T | null> {
|
|
118
117
|
const target = url(path, query);
|
|
119
|
-
const res = await doFetch(target, {
|
|
118
|
+
const res = await doFetch(target, {
|
|
119
|
+
headers: { Accept: "application/json" },
|
|
120
|
+
});
|
|
120
121
|
|
|
121
122
|
if (res.status === 404 && opts.nullOn404) return null;
|
|
122
123
|
|
|
@@ -124,52 +125,98 @@ export function createMisoApiClient(options: MisoApiClientOptions) {
|
|
|
124
125
|
const body = await res.json().catch(() => null);
|
|
125
126
|
const parsed = s.apiErrorSchema.safeParse(body);
|
|
126
127
|
throw parsed.success
|
|
127
|
-
? new MisoApiError(
|
|
128
|
-
|
|
128
|
+
? new MisoApiError(
|
|
129
|
+
res.status,
|
|
130
|
+
parsed.data.error.code,
|
|
131
|
+
parsed.data.error.message,
|
|
132
|
+
)
|
|
133
|
+
: new MisoApiError(
|
|
134
|
+
res.status,
|
|
135
|
+
"unknown",
|
|
136
|
+
`Request to ${path} failed (${res.status})`,
|
|
137
|
+
);
|
|
129
138
|
}
|
|
130
139
|
|
|
131
140
|
const json = await res.json().catch(() => {
|
|
132
|
-
throw new MisoApiError(
|
|
141
|
+
throw new MisoApiError(
|
|
142
|
+
res.status,
|
|
143
|
+
"bad_response",
|
|
144
|
+
`Response from ${path} was not JSON`,
|
|
145
|
+
);
|
|
133
146
|
});
|
|
134
147
|
|
|
135
148
|
const parsed = schema.safeParse(json);
|
|
136
|
-
if (!parsed.success)
|
|
149
|
+
if (!parsed.success)
|
|
150
|
+
throw new MisoApiContractError(path, parsed.error.issues);
|
|
137
151
|
return parsed.data;
|
|
138
152
|
}
|
|
139
153
|
|
|
140
154
|
/** Same as `request`, for endpoints that always return a body. */
|
|
141
|
-
async function required<T>(
|
|
155
|
+
async function required<T>(
|
|
156
|
+
schema: z.ZodType<T>,
|
|
157
|
+
path: string,
|
|
158
|
+
query?: Record<string, QueryValue>,
|
|
159
|
+
): Promise<T> {
|
|
142
160
|
return (await request(schema, path, query)) as T;
|
|
143
161
|
}
|
|
144
162
|
|
|
145
163
|
return {
|
|
146
164
|
// ── Catalog ─────────────────────────────────────────────────────────────
|
|
147
165
|
/** The records currently on sale. */
|
|
148
|
-
getDiscover: (): Promise<DiscoverShelf> =>
|
|
166
|
+
getDiscover: (): Promise<DiscoverShelf> =>
|
|
167
|
+
required(s.discoverShelfSchema, "/discover"),
|
|
149
168
|
|
|
150
169
|
/** A pressing and everything its buy page renders. `null` when there is no such pressing. */
|
|
151
|
-
getPressing: (
|
|
152
|
-
|
|
170
|
+
getPressing: (
|
|
171
|
+
pressingId: string,
|
|
172
|
+
opts: { include?: readonly "trackCredits"[] } = {},
|
|
173
|
+
): Promise<PressingDetail | null> =>
|
|
174
|
+
request(
|
|
175
|
+
s.pressingDetailSchema,
|
|
176
|
+
`/pressings/${pressingId}`,
|
|
177
|
+
{ include: opts.include?.join(",") },
|
|
178
|
+
{ nullOn404: true },
|
|
179
|
+
),
|
|
153
180
|
|
|
154
181
|
/** A release with its cover, credits, and resolved tracklist. */
|
|
155
|
-
getRelease: (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
182
|
+
getRelease: (
|
|
183
|
+
releaseId: string,
|
|
184
|
+
opts: { include?: readonly "trackCredits"[] } = {},
|
|
185
|
+
): Promise<ReleaseDetail | null> =>
|
|
186
|
+
request(
|
|
187
|
+
s.releaseDetailSchema,
|
|
188
|
+
`/releases/${releaseId}`,
|
|
189
|
+
{ include: opts.include?.join(",") },
|
|
190
|
+
{ nullOn404: true },
|
|
191
|
+
),
|
|
161
192
|
|
|
162
193
|
/** A record's parent release. Immutable for the life of the record. */
|
|
163
|
-
getRecordAlbum: (
|
|
164
|
-
|
|
194
|
+
getRecordAlbum: (
|
|
195
|
+
recordId: string,
|
|
196
|
+
opts: { include?: readonly ("release" | "trackCredits")[] } = {},
|
|
197
|
+
): Promise<RecordAlbum | null> =>
|
|
198
|
+
request(
|
|
199
|
+
s.recordAlbumSchema,
|
|
200
|
+
`/records/${recordId}/album`,
|
|
201
|
+
{ include: opts.include?.join(",") },
|
|
202
|
+
{ nullOn404: true },
|
|
203
|
+
),
|
|
165
204
|
|
|
166
205
|
/** Confirmation preview for a pasted pressing id. `null` when it isn't a pressing. */
|
|
167
206
|
getDropPreview: (dropId: string): Promise<DropPreview | null> =>
|
|
168
|
-
request(
|
|
207
|
+
request(
|
|
208
|
+
s.dropPreviewSchema,
|
|
209
|
+
`/drops/${dropId}/preview`,
|
|
210
|
+
{},
|
|
211
|
+
{ nullOn404: true },
|
|
212
|
+
),
|
|
169
213
|
|
|
170
214
|
// ── Artist ──────────────────────────────────────────────────────────────
|
|
171
|
-
/** An artist
|
|
172
|
-
getArtist: (
|
|
215
|
+
/** An artist plus optional relationship expansions. */
|
|
216
|
+
getArtist: (
|
|
217
|
+
partyId: string,
|
|
218
|
+
opts: { include?: readonly ("roles" | "tags" | "featured")[] } = {},
|
|
219
|
+
): Promise<ArtistProfile | null> =>
|
|
173
220
|
request(
|
|
174
221
|
s.artistProfileSchema,
|
|
175
222
|
`/artists/${partyId}`,
|
|
@@ -177,10 +224,6 @@ export function createMisoApiClient(options: MisoApiClientOptions) {
|
|
|
177
224
|
{ nullOn404: true },
|
|
178
225
|
),
|
|
179
226
|
|
|
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
227
|
/** Name + kind for many parties at once. */
|
|
185
228
|
getArtists: (ids: readonly string[]): Promise<PartySummary[]> =>
|
|
186
229
|
ids.length === 0
|
|
@@ -210,16 +253,28 @@ export function createMisoApiClient(options: MisoApiClientOptions) {
|
|
|
210
253
|
|
|
211
254
|
/** Whether the wallet holds this party's admin cap. Carries the cap id. */
|
|
212
255
|
ownsParty: (address: string, partyId: string): Promise<Ownership> =>
|
|
213
|
-
required(s.ownershipSchema, `/wallets/${address}/owns`, {
|
|
256
|
+
required(s.ownershipSchema, `/wallets/${address}/owns`, {
|
|
257
|
+
party: partyId,
|
|
258
|
+
}),
|
|
214
259
|
|
|
215
260
|
/** Whether the wallet owns this record. */
|
|
216
261
|
ownsRecord: (address: string, recordId: string): Promise<Ownership> =>
|
|
217
|
-
required(s.ownershipSchema, `/wallets/${address}/owns`, {
|
|
262
|
+
required(s.ownershipSchema, `/wallets/${address}/owns`, {
|
|
263
|
+
record: recordId,
|
|
264
|
+
}),
|
|
218
265
|
|
|
219
266
|
// ── Receipts ────────────────────────────────────────────────────────────
|
|
220
267
|
/** What one record sale was, re-derived from its transaction. */
|
|
221
|
-
getReceipt: (
|
|
222
|
-
|
|
268
|
+
getReceipt: (
|
|
269
|
+
pressingId: string,
|
|
270
|
+
txDigest: string,
|
|
271
|
+
): Promise<PurchaseReceipt | null> =>
|
|
272
|
+
request(
|
|
273
|
+
s.purchaseReceiptSchema,
|
|
274
|
+
`/receipts/${pressingId}/${txDigest}`,
|
|
275
|
+
{},
|
|
276
|
+
{ nullOn404: true },
|
|
277
|
+
),
|
|
223
278
|
};
|
|
224
279
|
}
|
|
225
280
|
|
|
@@ -254,11 +309,9 @@ export const READ_CACHE_CLASS = {
|
|
|
254
309
|
getDiscover: "sale",
|
|
255
310
|
getPressing: "sale",
|
|
256
311
|
getDropPreview: "sale",
|
|
257
|
-
getReleaseTrackCredits: "published",
|
|
258
312
|
getRecordAlbum: "immutable",
|
|
259
313
|
getReceipt: "immutable",
|
|
260
314
|
getArtist: "artist",
|
|
261
|
-
getArtistFeatured: "artist",
|
|
262
315
|
getArtists: "artist",
|
|
263
316
|
getWalletRecords: "private",
|
|
264
317
|
getWalletParties: "private",
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Miso read API.
|
|
6
6
|
//
|
|
7
7
|
// This package is what every Miso frontend reads through: the PWA, the CLI,
|
|
8
|
-
//
|
|
8
|
+
// and third-party clients. It carries no chain code and no Sui dependency — a browser importing it
|
|
9
9
|
// gets zod and a fetch wrapper, not a blockchain SDK.
|
|
10
10
|
//
|
|
11
11
|
// import { createMisoApiClient } from "@misofm/api-client";
|
|
@@ -19,7 +19,3 @@ export type { CacheClass, CachePolicy } from "./cache.ts";
|
|
|
19
19
|
|
|
20
20
|
export * as schemas from "./schemas.ts";
|
|
21
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
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
// · @misofm/api-client infers its types from it
|
|
7
7
|
// · miso-read-service generates its OpenAPI document from it
|
|
8
8
|
// · a contract test in that service parses every handler's real output through
|
|
9
|
-
// it, so a change in @
|
|
9
|
+
// it, so a change in @misofm/sdk/read that these schemas don't describe fails CI
|
|
10
10
|
// rather than reaching a client
|
|
11
11
|
//
|
|
12
12
|
// Schemas, not hand-written interfaces, precisely so that third clause is
|
|
13
|
-
// possible.
|
|
13
|
+
// possible. The CLI and any future client read the same OpenAPI.
|
|
14
14
|
//
|
|
15
15
|
// SCALARS: every u64/u128 is a DECIMAL STRING (`u64Schema`), never a number.
|
|
16
16
|
// Prices, supply counts, and balances routinely exceed 2^53, and JSON has no
|
|
@@ -63,9 +63,36 @@ export const trackViewSchema = z.object({
|
|
|
63
63
|
disc: z.number().int().min(1),
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
/** A recording's work-role credits and recording billing positions. */
|
|
67
|
+
export const recordingCreditsSchema = z.object({
|
|
68
|
+
credits: z.array(creditSchema),
|
|
69
|
+
primaryArtistIds: z.array(suiIdSchema),
|
|
70
|
+
featuredArtistIds: z.array(suiIdSchema),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** Per-track credits for a release. Composition writing and recording
|
|
74
|
+
* performance/production credits remain separate. */
|
|
75
|
+
const completeTrackCreditsSchema = z.object({
|
|
76
|
+
compositionCredits: z.array(creditSchema),
|
|
77
|
+
recordingCredits: recordingCreditsSchema,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** The HTTP wire accepts the complete shape and the deployed recording-only shape. */
|
|
81
|
+
export const trackCreditsWireSchema = z.union([
|
|
82
|
+
completeTrackCreditsSchema,
|
|
83
|
+
recordingCreditsSchema,
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
/** Clients always receive the complete shape, including during the rollout. */
|
|
87
|
+
export const trackCreditsSchema = trackCreditsWireSchema.transform((credits) =>
|
|
88
|
+
"recordingCredits" in credits
|
|
89
|
+
? credits
|
|
90
|
+
: { compositionCredits: [], recordingCredits: credits },
|
|
91
|
+
);
|
|
92
|
+
|
|
66
93
|
// ── Catalog ──────────────────────────────────────────────────────────────────
|
|
67
94
|
|
|
68
|
-
|
|
95
|
+
const releaseDetailBaseSchema = z.object({
|
|
69
96
|
id: suiIdSchema,
|
|
70
97
|
title: z.string(),
|
|
71
98
|
subtitle: z.string().nullable(),
|
|
@@ -79,6 +106,16 @@ export const releaseDetailSchema = z.object({
|
|
|
79
106
|
tracks: z.array(trackViewSchema),
|
|
80
107
|
});
|
|
81
108
|
|
|
109
|
+
/** Transform-free wire form used by OpenAPI generation. */
|
|
110
|
+
export const releaseDetailWireSchema = releaseDetailBaseSchema.extend({
|
|
111
|
+
trackCredits: z.record(suiIdSchema, trackCreditsWireSchema).optional(),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
export const releaseDetailSchema = releaseDetailBaseSchema.extend({
|
|
115
|
+
/** Present only when requested via `include`. */
|
|
116
|
+
trackCredits: z.record(suiIdSchema, trackCreditsSchema).optional(),
|
|
117
|
+
});
|
|
118
|
+
|
|
82
119
|
export const priceSchema = z.object({
|
|
83
120
|
/** `fixed` — pay exactly this. `floor` — pay at least this. */
|
|
84
121
|
kind: z.enum(["fixed", "floor"]),
|
|
@@ -111,6 +148,11 @@ export const pressingDetailSchema = z.object({
|
|
|
111
148
|
release: releaseDetailSchema,
|
|
112
149
|
});
|
|
113
150
|
|
|
151
|
+
export const pressingDetailWireSchema = z.object({
|
|
152
|
+
pressing: pressingViewSchema,
|
|
153
|
+
release: releaseDetailWireSchema,
|
|
154
|
+
});
|
|
155
|
+
|
|
114
156
|
export const discoverItemSchema = z.object({
|
|
115
157
|
pressing: pressingViewSchema,
|
|
116
158
|
releaseId: suiIdSchema,
|
|
@@ -125,6 +167,14 @@ export const discoverShelfSchema = z.array(discoverItemSchema);
|
|
|
125
167
|
export const recordAlbumSchema = z.object({
|
|
126
168
|
recordId: suiIdSchema,
|
|
127
169
|
releaseId: suiIdSchema.nullable(),
|
|
170
|
+
/** Present only when requested via `include`. */
|
|
171
|
+
release: releaseDetailSchema.nullable().optional(),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
export const recordAlbumWireSchema = z.object({
|
|
175
|
+
recordId: suiIdSchema,
|
|
176
|
+
releaseId: suiIdSchema.nullable(),
|
|
177
|
+
release: releaseDetailWireSchema.nullable().optional(),
|
|
128
178
|
});
|
|
129
179
|
|
|
130
180
|
export const dropPreviewSchema = z.object({
|
|
@@ -137,33 +187,6 @@ export const dropPreviewSchema = z.object({
|
|
|
137
187
|
trackCount: z.number().int().min(0),
|
|
138
188
|
});
|
|
139
189
|
|
|
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
190
|
// ── Artist ───────────────────────────────────────────────────────────────────
|
|
168
191
|
|
|
169
192
|
export const partyMemberSchema = z.object({
|
|
@@ -220,6 +243,14 @@ export const partyLinkSchema = z.object({
|
|
|
220
243
|
|
|
221
244
|
export const partyCtaSchema = z.object({ label: z.string(), url: z.string() });
|
|
222
245
|
|
|
246
|
+
export const featuredReleaseSchema = z.object({
|
|
247
|
+
pressingId: suiIdSchema,
|
|
248
|
+
releaseId: suiIdSchema,
|
|
249
|
+
title: z.string(),
|
|
250
|
+
artist: z.string(),
|
|
251
|
+
coverUrl: z.string().url().nullable(),
|
|
252
|
+
});
|
|
253
|
+
|
|
223
254
|
export const artistProfileSchema = z.object({
|
|
224
255
|
id: suiIdSchema,
|
|
225
256
|
kind: z.enum(["individual", "group"]),
|
|
@@ -237,17 +268,11 @@ export const artistProfileSchema = z.object({
|
|
|
237
268
|
/** Present only when requested via `include` — the owner-editor fields. */
|
|
238
269
|
roles: z.array(z.string()).optional(),
|
|
239
270
|
tags: z.array(z.string()).optional(),
|
|
271
|
+
/** Present only when requested; null means the artist has no pinned release. */
|
|
272
|
+
featured: featuredReleaseSchema.nullable().optional(),
|
|
240
273
|
avatarUrl: z.string().url(),
|
|
241
274
|
});
|
|
242
275
|
|
|
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
276
|
export const partySummarySchema = z.object({
|
|
252
277
|
id: suiIdSchema,
|
|
253
278
|
name: z.string(),
|
|
@@ -345,6 +370,13 @@ export const purchaseReceiptSchema = z.object({
|
|
|
345
370
|
tracks: z.array(trackRoyaltySchema),
|
|
346
371
|
});
|
|
347
372
|
|
|
373
|
+
export const purchaseReceiptWireSchema = z.object({
|
|
374
|
+
sale: recordSaleSchema,
|
|
375
|
+
detail: pressingDetailWireSchema,
|
|
376
|
+
price: u64Schema,
|
|
377
|
+
tracks: z.array(trackRoyaltySchema),
|
|
378
|
+
});
|
|
379
|
+
|
|
348
380
|
// ── Errors ───────────────────────────────────────────────────────────────────
|
|
349
381
|
|
|
350
382
|
/** The envelope every non-2xx carries, matching miso-api's `apiError`. */
|
package/src/types.ts
CHANGED
|
@@ -15,7 +15,6 @@ export type TrackView = z.infer<typeof s.trackViewSchema>;
|
|
|
15
15
|
|
|
16
16
|
export type ReleaseDetail = z.infer<typeof s.releaseDetailSchema>;
|
|
17
17
|
export type TrackCredits = z.infer<typeof s.trackCreditsSchema>;
|
|
18
|
-
export type ReleaseTrackCredits = z.infer<typeof s.releaseTrackCreditsSchema>;
|
|
19
18
|
export type Price = z.infer<typeof s.priceSchema>;
|
|
20
19
|
export type Currency = z.infer<typeof s.currencySchema>;
|
|
21
20
|
export type PressingView = z.infer<typeof s.pressingViewSchema>;
|
package/src/checkout.ts
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
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>;
|