···11import { expect, test } from '@playwright/test';
2233-// Coverage boundary: the publish wizard renders only for an authenticated
44-// ATProto session, and a live PDS publish needs real OAuth, which this CI e2e
55-// harness cannot establish. So e2e covers the signed-out callout (no wizard);
66-// the signed-in wizard assembly, draft round-trip, validation, and promote-on-
77-// publish are covered by the Rust unit/integration suites (src/publish/draft.rs,
88-// src/appview/drafts.rs) plus the manual Playwright pass against `just serve`.
33+// Coverage boundary: the publish flow renders only for an authenticated ATProto
44+// session, and a live PDS publish needs real OAuth, which this CI e2e harness
55+// cannot establish. So e2e covers the signed-out callout (no flow); the
66+// signed-in upload-first assembly, draft round-trip, validation, and
77+// promote-on-publish are covered by the Rust unit/integration suites
88+// (src/publish/draft.rs, src/appview/drafts.rs) plus the manual Playwright pass
99+// against `just serve`.
9101010-test('signed-out /publish shows the sign-in callout, not the wizard', async ({ page }) => {
1111+test('signed-out /publish shows the sign-in callout, not the flow', async ({ page }) => {
1112 await page.goto('/publish');
12131314 await expect(page.getByRole('heading', { name: 'Share your models' })).toBeVisible();
1415 const callout = page.locator('section[aria-label="Sign in"]');
1516 await expect(callout.getByRole('button', { name: 'Sign in' })).toBeVisible();
16171717- // The signed-out surface must not expose the wizard stepper or publish action.
1818- await expect(page.getByRole('navigation', { name: 'Publish steps' })).toHaveCount(0);
1818+ // The signed-out surface must not expose the hero upload zone or publish action.
1919+ await expect(page.getByLabel('Upload model files')).toHaveCount(0);
1920 await expect(page.getByRole('button', { name: 'Publish', exact: true })).toHaveCount(0);
2021});
2122
···11-//! Same-origin client calls for the PM-43 publish wizard.
11+//! Same-origin client calls for the publish flow.
22//!
33//! Thin typed wrappers over [`PolymodelClient`]'s raw app-request helpers for
44//! the draft store, raw-byte file staging, and image upload. Every call rides
55//! the browser cookie session (same-origin), so these are authenticated without
66//! any explicit token handling.
77+//!
88+//! [`resolve_strong_ref`] is the exception: an unauthenticated, identity-resolving
99+//! `get_record` that reads a thing record directly from the target repo's own
1010+//! PDS, so a cross-repo "Works with" link resolves.
711812use http::Method;
1313+use jacquard::client::{AgentSessionExt, BasicClient};
1414+use jacquard::common::types::string::AtUri;
1515+use polymodel_api::com_atproto::repo::strong_ref::StrongRef;
916use polymodel_api::space_polymodel::library::Image;
1017use polymodel_api::space_polymodel::library::publish_thing::PublishThingOutput;
1118use polymodel_api::space_polymodel::library::stage_file::StageFileOutput;
1919+use polymodel_api::space_polymodel::library::thing::Thing;
12201321use crate::client::{PolymodelClient, RawError};
1422use crate::publish::draft::{
1523 DeleteDraftResponse, DraftIdResponse, DraftSummary, DraftThingInput, ImageUploadResponse,
1624};
2525+2626+/// A resolved compatibility target: the `StrongRef` to persist plus the thing's
2727+/// display name, shown on the chip.
2828+pub struct ResolvedRef {
2929+ pub strong_ref: StrongRef,
3030+ pub name: Option<String>,
3131+}
3232+3333+/// Resolve a thing `AtUri` to a current `StrongRef` (uri + cid) by reading the
3434+/// record directly from the target repo's own PDS.
3535+///
3636+/// Uses an unauthenticated, identity-resolving jacquard agent: `get_record`
3737+/// resolves the URI's authority (DID or handle) to its PDS and reads from there,
3838+/// so a thing in *another* maker's repo resolves (unlike the same-origin proxy,
3939+/// which is pinned to the signed-in user's PDS). A record with no `cid` is an
4040+/// error — a `StrongRef` requires one.
4141+pub async fn resolve_strong_ref(uri: &AtUri) -> Result<ResolvedRef, RawError> {
4242+ let agent = BasicClient::unauthenticated();
4343+ let response = agent
4444+ .get_record::<Thing, _>(uri)
4545+ .await
4646+ .map_err(|error| RawError::Transport(error.to_string()))?;
4747+ // `into_output()` fails for typed XRPC errors — a `RecordNotFound` for a
4848+ // missing record, or a decode/validation error otherwise. Both are surfaced
4949+ // as a decode failure carrying the real cause rather than a fake HTTP status.
5050+ let output = response
5151+ .into_output()
5252+ .map_err(|error| RawError::Decode(error.to_string()))?;
5353+ let cid = output
5454+ .cid
5555+ .ok_or_else(|| RawError::Decode("record has no cid".to_string()))?;
5656+ let name = {
5757+ let n = output.value.name.as_str().trim();
5858+ (!n.is_empty()).then(|| n.to_string())
5959+ };
6060+ Ok(ResolvedRef {
6161+ strong_ref: StrongRef {
6262+ cid,
6363+ uri: output.uri,
6464+ extra_data: None,
6565+ },
6666+ name,
6767+ })
6868+}
17691870/// stageFile XRPC path (raw-body upload), reused from the existing write path.
1971const STAGE_FILE_PATH: &str = "/xrpc/space.polymodel.library.stageFile";