From 011ad11b3dce1852100ed09494434933703921d1 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Fri, 3 Apr 2026 00:33:14 +0000 Subject: [PATCH] fix!: catch AuthenticationError in pull/resolve fallback chain for public bundles When pulling a public bundle without MUSHER_API_KEY, the namespace-scoped :resolve and :pull endpoints return 401. The SDK now catches AuthenticationError (in addition to ForbiddenError) and falls back to the public hub endpoints, matching the CLI behavior. Closes #20 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/musher/src/client.ts | 143 ++++++++++++++++++++++++--- packages/musher/tests/client.test.ts | 119 +++++++++++++++++++++- 2 files changed, 246 insertions(+), 16 deletions(-) diff --git a/packages/musher/src/client.ts b/packages/musher/src/client.ts index 1393092..b741b7c 100644 --- a/packages/musher/src/client.ts +++ b/packages/musher/src/client.ts @@ -9,7 +9,13 @@ import { createHash } from "node:crypto"; import { Bundle } from "./bundle.js"; import { BundleCache } from "./cache.js"; import { type ClientConfig, resolveConfig } from "./config.js"; -import { ApiError, ForbiddenError, IntegrityError, NotFoundError } from "./errors.js"; +import { + ApiError, + AuthenticationError, + ForbiddenError, + IntegrityError, + NotFoundError, +} from "./errors.js"; import { HttpTransport } from "./http.js"; import { BundleRef } from "./ref.js"; import { BundlesResource } from "./resources/bundles.js"; @@ -51,12 +57,24 @@ export class MusherClient { const resolvedVersion = version ?? parsed.version; // Resolve metadata first (needed for manifest hashes and cache keys) - const resolved = await this.bundles.resolve( - parsed.namespace, - parsed.slug, - resolvedVersion, - parsed.digest, - ); + let resolved: BundleResolveOutput; + try { + resolved = await this.bundles.resolve( + parsed.namespace, + parsed.slug, + resolvedVersion, + parsed.digest, + ); + } catch (error) { + if ( + !(error instanceof AuthenticationError || error instanceof ForbiddenError) || + !resolvedVersion + ) { + throw error; + } + // Namespace resolve requires auth — fall back to hub-only pull + return this.pullFromHub(parsed.namespace, parsed.slug, resolvedVersion); + } // Pull asset content — try :pull endpoint (single request), fall back to // individual asset fetches if the caller lacks namespace access. @@ -130,12 +148,26 @@ export class MusherClient { } // Cache miss or stale — call the API - const resolved = await this.bundles.resolve( - parsed.namespace, - parsed.slug, - resolvedVersion, - parsed.digest, - ); + let resolved: BundleResolveOutput; + try { + resolved = await this.bundles.resolve( + parsed.namespace, + parsed.slug, + resolvedVersion, + parsed.digest, + ); + } catch (error) { + if ( + !(error instanceof AuthenticationError || error instanceof ForbiddenError) || + !resolvedVersion + ) { + throw error; + } + // Namespace resolve requires auth — fall back to hub pull for metadata + resolved = await this.resolveFromHub(parsed.namespace, parsed.slug, resolvedVersion); + await this._cache.writeManifest(resolved); + return resolved; + } // Persist resolved manifest to disk cache await this._cache.writeManifest(resolved); @@ -149,6 +181,83 @@ export class MusherClient { return resolved; } + /** + * Build a synthetic BundleResolveOutput from hub pull data. + * Used when namespace :resolve requires auth and we fall back to the public hub. + */ + private async resolveFromHub( + namespace: string, + slug: string, + version: string, + ): Promise { + const pulled = await this.bundles.pullHubVersion(namespace, slug, version); + + const layers = pulled.manifest.map((asset) => { + const buf = Buffer.from(asset.contentText, "utf-8"); + return { + assetId: "", + logicalPath: asset.logicalPath, + assetType: asset.assetType, + contentSha256: createHash("sha256").update(buf).digest("hex"), + sizeBytes: buf.length, + mediaType: asset.mediaType ?? null, + }; + }); + + return { + bundleId: "", + versionId: "", + namespace, + slug, + ref: `${namespace}/${slug}`, + version: pulled.version, + sourceType: "registry", + state: "published", + manifest: { layers }, + }; + } + + /** + * Pull a public bundle entirely via the hub endpoint (no auth required). + * Used when namespace :resolve returns 401/403. + */ + private async pullFromHub(namespace: string, slug: string, version: string): Promise { + const pulled = await this.bundles.pullHubVersion(namespace, slug, version); + + const assets = new Map(); + const layers = []; + + for (const asset of pulled.manifest) { + const buf = Buffer.from(asset.contentText, "utf-8"); + const hash = createHash("sha256").update(buf).digest("hex"); + assets.set(asset.logicalPath, buf); + layers.push({ + assetId: "", + logicalPath: asset.logicalPath, + assetType: asset.assetType, + contentSha256: hash, + sizeBytes: buf.length, + mediaType: asset.mediaType ?? null, + }); + } + + const resolved: BundleResolveOutput = { + bundleId: "", + versionId: "", + namespace, + slug, + ref: `${namespace}/${slug}`, + version: pulled.version, + sourceType: "registry", + state: "published", + manifest: { layers }, + }; + + await this._cache.write(resolved, assets); + + return new Bundle(resolved, assets); + } + /** * Pull content via the :pull endpoint with automatic fallback. * @@ -165,7 +274,13 @@ export class MusherClient { try { return await this.bundles.pullVersion(namespace, slug, resolved.version); } catch (error) { - if (!(error instanceof ForbiddenError || error instanceof NotFoundError)) { + if ( + !( + error instanceof AuthenticationError || + error instanceof ForbiddenError || + error instanceof NotFoundError + ) + ) { throw error; } } diff --git a/packages/musher/tests/client.test.ts b/packages/musher/tests/client.test.ts index 04f392a..0b8b629 100644 --- a/packages/musher/tests/client.test.ts +++ b/packages/musher/tests/client.test.ts @@ -1,9 +1,53 @@ -import { describe, expect, it } from "vitest"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; import { MusherClient } from "../src/client.js"; -import { MusherError } from "../src/errors.js"; +import { AuthenticationError, ForbiddenError, MusherError } from "../src/errors.js"; const INVALID_BUNDLE_REF_RE = /Invalid bundle ref/; +function makeProblem(status: number, title: string) { + return { type: "about:blank", title, status, detail: title }; +} + +function makePullOutput(ns: string, slug: string, version: string) { + return { + namespace: ns, + slug, + version, + name: `${ns}/${slug}`, + description: null, + manifest: [ + { + logicalPath: "skills/greet/SKILL.md", + assetType: "skill", + contentText: "# Greet\nSay hello", + mediaType: "text/markdown", + }, + ], + }; +} + +function makeResolveOutput(ns: string, slug: string, version: string) { + return { + bundleId: "00000000-0000-0000-0000-000000000001", + versionId: "00000000-0000-0000-0000-000000000002", + namespace: ns, + slug, + ref: `${ns}/${slug}`, + version, + sourceType: "registry" as const, + state: "published" as const, + manifest: { layers: [] }, + }; +} + +async function makeTempClient() { + const cacheDir = await mkdtemp(join(tmpdir(), "musher-test-")); + return new MusherClient({ cacheDir }); +} + describe("MusherClient", () => { it("creates with default config", () => { const client = new MusherClient(); @@ -41,4 +85,75 @@ describe("MusherClient", () => { // Will fail due to network, but should not throw a ref parse error await expect(client.pull("acme/bundle:1.0.0")).rejects.not.toThrow(INVALID_BUNDLE_REF_RE); }); + + describe("pullContent fallback", () => { + it("falls back to hub pull when namespace :pull returns 401", async () => { + const client = await makeTempClient(); + const pullOutput = makePullOutput("acme", "bundle", "1.0.0"); + + vi.spyOn(client.bundles, "resolve").mockResolvedValue( + makeResolveOutput("acme", "bundle", "1.0.0"), + ); + vi.spyOn(client.bundles, "pullVersion").mockRejectedValue( + new AuthenticationError(makeProblem(401, "Unauthorized")), + ); + vi.spyOn(client.bundles, "pullHubVersion").mockResolvedValue(pullOutput); + + const bundle = await client.pull("acme/bundle:1.0.0"); + expect(bundle).toBeDefined(); + expect(client.bundles.pullHubVersion).toHaveBeenCalledWith("acme", "bundle", "1.0.0"); + }); + + it("falls back to hub pull when namespace :pull returns 403", async () => { + const client = await makeTempClient(); + const pullOutput = makePullOutput("acme", "bundle", "1.0.0"); + + vi.spyOn(client.bundles, "resolve").mockResolvedValue( + makeResolveOutput("acme", "bundle", "1.0.0"), + ); + vi.spyOn(client.bundles, "pullVersion").mockRejectedValue( + new ForbiddenError(makeProblem(403, "Forbidden")), + ); + vi.spyOn(client.bundles, "pullHubVersion").mockResolvedValue(pullOutput); + + const bundle = await client.pull("acme/bundle:1.0.0"); + expect(bundle).toBeDefined(); + expect(client.bundles.pullHubVersion).toHaveBeenCalledWith("acme", "bundle", "1.0.0"); + }); + }); + + describe("resolve fallback to hub", () => { + it("pull() falls back to hub-only flow when resolve returns 401", async () => { + const client = await makeTempClient(); + const pullOutput = makePullOutput("acme", "bundle", "1.0.0"); + + vi.spyOn(client.bundles, "resolve").mockRejectedValue( + new AuthenticationError(makeProblem(401, "Unauthorized")), + ); + vi.spyOn(client.bundles, "pullHubVersion").mockResolvedValue(pullOutput); + + const bundle = await client.pull("acme/bundle:1.0.0"); + expect(bundle).toBeDefined(); + expect(bundle.files().length).toBe(1); + expect(client.bundles.pullHubVersion).toHaveBeenCalledWith("acme", "bundle", "1.0.0"); + }); + + it("resolve() falls back to hub pull for metadata when namespace resolve returns 401", async () => { + const client = await makeTempClient(); + const pullOutput = makePullOutput("acme", "bundle", "1.0.0"); + + vi.spyOn(client.bundles, "resolve").mockRejectedValue( + new AuthenticationError(makeProblem(401, "Unauthorized")), + ); + vi.spyOn(client.bundles, "pullHubVersion").mockResolvedValue(pullOutput); + + const resolved = await client.resolve("acme/bundle:1.0.0"); + expect(resolved).toBeDefined(); + expect(resolved.namespace).toBe("acme"); + expect(resolved.slug).toBe("bundle"); + expect(resolved.version).toBe("1.0.0"); + expect(resolved.manifest?.layers?.length).toBe(1); + expect(client.bundles.pullHubVersion).toHaveBeenCalledWith("acme", "bundle", "1.0.0"); + }); + }); });