diff --git a/bun.lock b/bun.lock index e1bee6e..9f3ba0e 100644 --- a/bun.lock +++ b/bun.lock @@ -74,7 +74,7 @@ }, "packages/platform": { "name": "@misofm/platform", - "version": "0.43.0", + "version": "0.44.0", "dependencies": { "@misofm/musicos": "0.7.0", "@misofm/partyos": "0.7.0", diff --git a/packages/platform/README.md b/packages/platform/README.md index 83a985a..96f098c 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -285,6 +285,13 @@ objects into the JSON-safe views a client actually renders. It works in browsers Workers, and servers. Miso's HTTP API is a thin validated and cached transport over this same surface, not a separate domain implementation. +Version 0.44 targets the public `/v1` API namespace. `MisoConfig.apiBaseUrl` is +the versioned API base (for example, `https://api.testnet.miso.fm/v1`), and +artist profiles now return canonical party avatar URLs at +`/v1/parties/:partyId/avatar`. Authenticated platform mutations use the same +canonical paths while retaining exact legacy `/platform/...` challenge targets +for migration clients. + Every read here takes the resolved `MisoConfig` (package ids, not a transport) and declares `Sui`/`SuiGraphQL` in its Requirements — `createMisoClient` (`read/client.ts`) still bundles the transport, config, and the `client.miso` diff --git a/packages/platform/package.json b/packages/platform/package.json index 41039c5..5ee44cd 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -1,6 +1,6 @@ { "name": "@misofm/platform", - "version": "0.43.0", + "version": "0.44.0", "packageManager": "bun@1.4.0", "description": "Complete client SDK for the Miso platform on Sui \u2014 high-level reads, pressings, listings, records, work publishing, Party extensions, and the first-party extensions built on top of the @misofm/musicos object model and the @misofm/partyos Party identity model.", "license": "Apache-2.0", diff --git a/packages/platform/src/auth.ts b/packages/platform/src/auth.ts index 76c0468..fed61ab 100644 --- a/packages/platform/src/auth.ts +++ b/packages/platform/src/auth.ts @@ -36,6 +36,26 @@ export const MISO_AUTH_HEADERS = { issuedAt: "X-Sui-Issued-At", } as const; +/** + * The small set of platform mutations that use the account challenge. Keep + * this list explicit: accepting every mutation under `/v1` would let a caller + * reuse this signer for an unrelated endpoint with the same bearer token. + * + * The `/platform` spellings are temporary migration targets. Their path is + * passed through byte-for-byte to the challenge and signature verifier so a + * legacy client keeps signing the path it actually sends. + */ +const SUPPORTED_AUTHORIZATION_TARGETS: ReadonlyArray = [ + ["PUT", /^\/v1\/usernames\/[a-z0-9-]{3,32}$/], + ["POST", /^\/v1\/media\/party-bundles$/], + ["PUT", /^\/v1\/parties\/0x[0-9a-fA-F]{1,64}\/avatar$/], + ["PUT", /^\/v1\/me\/avatar$/], + ["PUT", /^\/platform\/usernames\/[a-z0-9-]{3,32}$/], + ["POST", /^\/platform\/media\/party$/], + ["PUT", /^\/platform\/media\/avatar\/user$/], + ["PUT", /^\/platform\/media\/avatar\/0x[0-9a-fA-F]{1,64}$/], +]; + /** The byte-exact Sui personal message required for an authenticated API mutation. */ export function buildApiAuthorizationPayload(fields: ApiAuthorizationFields): string { return [ @@ -50,11 +70,13 @@ export function buildApiAuthorizationPayload(fields: ApiAuthorizationFields): st } export function isValidAuthorizationTarget(method: string, path: string): boolean { + const normalizedMethod = method.toUpperCase(); return ( - ["POST", "PUT", "PATCH", "DELETE"].includes(method.toUpperCase()) && - path.startsWith("/platform/") && path.length <= 2_048 && - !/[\r\n?#]/.test(path) + !/[\r\n?#]/.test(path) && + SUPPORTED_AUTHORIZATION_TARGETS.some(([targetMethod, target]) => + targetMethod === normalizedMethod && target.test(path), + ) ); } @@ -91,7 +113,7 @@ const target = Effect.fn("target")(function* ( if (!isValidAuthorizationTarget(normalized.method, normalized.path)) { return yield* new MisoAuthError({ code: "invalid_target", - reason: "Authenticated requests require a mutation under /platform/.", + reason: "That method and path is not a supported authenticated platform mutation.", }); } return normalized; @@ -201,7 +223,7 @@ export const requestAuthorizationChallenge = Effect.fn("requestAuthorizationChal } const apiUrl = new URL(options.apiUrl); const challengeUrl = options.challengeUrl === undefined - ? new URL("/platform/auth/challenge", apiUrl.origin) + ? new URL("/v1/auth/challenges", apiUrl.origin) : new URL(options.challengeUrl, apiUrl); const fetcher = options.fetch ?? globalThis.fetch; const response = yield* Effect.tryPromise({ diff --git a/packages/platform/src/read/artist.ts b/packages/platform/src/read/artist.ts index ed9214e..44cff77 100644 --- a/packages/platform/src/read/artist.ts +++ b/packages/platform/src/read/artist.ts @@ -33,7 +33,7 @@ export interface GetArtistOptions { /** Public avatar URL for a party (served from the API's R2 lane; 404 when unset). */ export function partyAvatarUrl(apiBaseUrl: string, partyId: string): string { - return `${apiBaseUrl.replace(/\/$/, "")}/media/avatar/${partyId}`; + return `${apiBaseUrl.replace(/\/+$/, "")}/parties/${encodeURIComponent(partyId)}/avatar`; } /** diff --git a/packages/platform/src/read/config.ts b/packages/platform/src/read/config.ts index c8a958c..4f0d821 100644 --- a/packages/platform/src/read/config.ts +++ b/packages/platform/src/read/config.ts @@ -84,9 +84,10 @@ export interface MisoConfig { /** Walrus aggregator serving cover art and party media for this network. */ walrusAggregatorUrl: string; /** - * Public origin of the Miso API, used to build avatar URLs (`/media/avatar/…`). - * Avatars are an R2-backed API lane, not on-chain data, so the SDK needs the - * public host to hand back a URL a browser can actually fetch. + * Public versioned API base, used to build avatar URLs + * (`/v1/parties/:partyId/avatar`). Avatars are an R2-backed API lane, not + * on-chain data, so the SDK needs the public base to hand back a URL a + * browser can actually fetch. */ apiBaseUrl: string; /** Explicit currency offers on the Discover shelf, in display order. */ @@ -150,7 +151,7 @@ export function configFromDeployment(deployment: MisoPlatformDeployment, overrid grpcUrl: `https://fullnode.${deployment.network}.sui.io`, graphqlUrl: `https://graphql.${deployment.network}.sui.io/graphql`, walrusAggregatorUrl: walrusAggregatorUrl(deployment.network), - apiBaseUrl: `https://api.${deployment.network}.miso.fm`, + apiBaseUrl: `https://api.${deployment.network}.miso.fm/v1`, discoverSales: [], }; return { ...config, ...stripUndefined(overrides) }; diff --git a/packages/platform/tests/auth.test.ts b/packages/platform/tests/auth.test.ts index 3ffe795..73586e6 100644 --- a/packages/platform/tests/auth.test.ts +++ b/packages/platform/tests/auth.test.ts @@ -10,6 +10,7 @@ import { authenticatedFetch, buildApiAuthorizationPayload, createAuthorizationHeaders, + isValidAuthorizationTarget, type AuthorizationChallenge, } from "../src/auth.ts"; @@ -31,6 +32,32 @@ function challenge( } describe("auth contract", () => { + test("accepts only supported canonical mutations and their exact legacy spellings", () => { + for (const [method, path] of [ + ["PUT", "/v1/usernames/alice"], + ["POST", "/v1/media/party-bundles"], + ["PUT", `/v1/parties/${ADDRESS}/avatar`], + ["PUT", "/v1/me/avatar"], + ["PUT", "/platform/usernames/alice"], + ["POST", "/platform/media/party"], + ["PUT", "/platform/media/avatar/user"], + ["PUT", `/platform/media/avatar/${ADDRESS}`], + ] as const) { + expect(isValidAuthorizationTarget(method, path)).toBe(true); + } + + for (const [method, path] of [ + ["POST", "/v1/unknown"], + ["PATCH", "/v1/usernames/alice"], + ["PUT", "/v1/usernames/not valid"], + ["PUT", "/v1/parties/not-an-address/avatar"], + ["PUT", "/v1/me/avatar?admin=true"], + ["DELETE", "/platform/anything"], + ] as const) { + expect(isValidAuthorizationTarget(method, path)).toBe(false); + } + }); + test("builds the byte-exact personal message", () => { expect(challenge().payload).toBe([ "miso.fm API authorization v1", @@ -60,7 +87,7 @@ describe("auth contract", () => { }, }, fetch: (async (input, init) => { - expect(String(input)).toBe("https://api.testnet.miso.fm/platform/auth/challenge"); + expect(String(input)).toBe("https://api.testnet.miso.fm/v1/auth/challenges"); expect(init?.method).toBe("POST"); expect(new Headers(init?.headers).get("authorization")).toBe("Bearer oidc-token"); return Response.json(challenge()); @@ -120,7 +147,7 @@ describe("auth contract", () => { }, fetch: (async (input, init) => { const url = String(input); - if (url.endsWith("/platform/auth/challenge")) { + if (url.endsWith("/v1/auth/challenges")) { calls.push("challenge"); return Response.json(challenge("PUT", "/platform/usernames/alice", nowMs)); } @@ -136,6 +163,83 @@ describe("auth contract", () => { expect(calls).toEqual(["challenge", "sign", "mutation"]); }); + for (const [method, path, legacyPath] of [ + ["PUT", "/v1/usernames/alice", "/platform/usernames/alice"], + ["POST", "/v1/media/party-bundles", "/platform/media/party"], + ["PUT", "/v1/parties/0xAbC/avatar", "/platform/media/avatar/0xAbC"], + ["PUT", "/v1/me/avatar", "/platform/media/avatar/user"], + ] as const) { + test(`signs and submits the exact canonical target ${path}`, async () => { + const calls: string[] = []; + const issuedChallenge = challenge(method, path, Date.now()); + const body = JSON.stringify({ example: "unchanged" }); + const response = await Effect.runPromise(authenticatedFetch( + `https://api.testnet.miso.fm${path}`, + { + method, + body, + auth: { + token: "oidc-token", + address: ADDRESS, + network: "testnet", + signer: { + async signPersonalMessage(message) { + calls.push("sign"); + expect(new TextDecoder().decode(message)).toBe(issuedChallenge.payload); + return { signature: "sui-signature" }; + }, + }, + }, + fetch: (async (input, init) => { + if (String(input) === "https://api.testnet.miso.fm/v1/auth/challenges") { + calls.push("challenge"); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ method, path }); + return Response.json(issuedChallenge); + } + calls.push("mutation"); + expect(String(input)).toBe(`https://api.testnet.miso.fm${path}`); + expect(init?.method).toBe(method); + expect(init?.body).toBe(body); + expect(new Headers(init?.headers).get(MISO_AUTH_HEADERS.signature)).toBe("sui-signature"); + return Response.json({ ok: true }); + }) as typeof fetch, + }, + )); + expect(response.ok).toBe(true); + expect(calls).toEqual(["challenge", "sign", "mutation"]); + }); + + test(`rejects the legacy challenge for ${path} before signing or submitting`, async () => { + let fetchCalls = 0; + let signCalls = 0; + const error = await Effect.runPromise(authenticatedFetch( + `https://api.testnet.miso.fm${path}`, + { + method, + auth: { + token: "oidc-token", + address: ADDRESS, + network: "testnet", + signer: { + async signPersonalMessage() { + signCalls += 1; + return { signature: "must-not-sign" }; + }, + }, + }, + fetch: (async () => { + fetchCalls += 1; + return Response.json(challenge(method, legacyPath, Date.now())); + }) as unknown as typeof fetch, + }, + ).pipe(Effect.flip)); + expect(error).toMatchObject({ code: "invalid_challenge" }); + expect(fetchCalls).toBe(1); + expect(signCalls).toBe(0); + }); + } + test("surfaces a rejected challenge without invoking the signer", async () => { let signed = false; const error = await Effect.runPromise( diff --git a/packages/platform/tests/read/config.test.ts b/packages/platform/tests/read/config.test.ts index 89142e8..416be69 100644 --- a/packages/platform/tests/read/config.test.ts +++ b/packages/platform/tests/read/config.test.ts @@ -4,6 +4,7 @@ import { describe, expect, test } from "bun:test"; import { getMisoPlatformDeployment } from "../../src/deployments.ts"; import { configFromDeployment, misoConfig } from "../../src/read/config.ts"; +import { partyAvatarUrl } from "../../src/read/artist.ts"; describe("misoConfig", () => { test("derives Testnet read ids from the bundled platform deployment", () => { @@ -12,6 +13,7 @@ describe("misoConfig", () => { expect(config.deployment).toBe(deployment.protocol); expect(config.recordSales).toBe(deployment.recordSales); + expect(config.apiBaseUrl).toBe("https://api.testnet.miso.fm/v1"); expect(config.protocol).toEqual({ vault: null, releaseCoverArt: deployment.packages.releaseCoverArt, @@ -30,6 +32,11 @@ describe("misoConfig", () => { }); }); + test("builds canonical versioned party avatar URLs and confines the id to one segment", () => { + expect(partyAvatarUrl("https://api.testnet.miso.fm/v1/", "0xabc/../../health?x=1")) + .toBe("https://api.testnet.miso.fm/v1/parties/0xabc%2F..%2F..%2Fhealth%3Fx%3D1/avatar"); + }); + test("allows endpoint and shelf overrides without erasing defaults", () => { const config = misoConfig("testnet", { grpcUrl: "https://example.test",