Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions packages/platform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion packages/platform/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
32 changes: 27 additions & 5 deletions packages/platform/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<readonly [string, RegExp]> = [
["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 [
Expand All @@ -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),
)
);
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion packages/platform/src/read/artist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}

/**
Expand Down
9 changes: 5 additions & 4 deletions packages/platform/src/read/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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) };
Expand Down
108 changes: 106 additions & 2 deletions packages/platform/tests/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
authenticatedFetch,
buildApiAuthorizationPayload,
createAuthorizationHeaders,
isValidAuthorizationTarget,
type AuthorizationChallenge,
} from "../src/auth.ts";

Expand All @@ -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",
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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));
}
Expand All @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions packages/platform/tests/read/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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,
Expand All @@ -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",
Expand Down
Loading