···7070 await page.goto('/search');
7171 await expect(page.getByRole('heading', { name: 'Search', exact: true })).toBeVisible();
7272 await expect(page.getByText('Not wired up yet')).toBeVisible();
7373-7474- await page.goto('/publish');
7575- await expect(page.getByRole('heading', { name: 'Publish a thing' })).toBeVisible();
7676- await expect(page.getByText('Coming soon')).toBeVisible();
7777- // Anonymous publish prompts sign-in (returns the user to /publish).
7878- await expect(page.getByRole('heading', { name: 'Sign in to publish' })).toBeVisible();
7973});
80748175test('home thing cards remain readable at narrow widths', async ({ page }) => {
+28
e2e/tests/publish.spec.ts
···11+import { expect, test } from '@playwright/test';
22+33+// 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`.
99+1010+test('signed-out /publish shows the sign-in callout, not the wizard', async ({ page }) => {
1111+ await page.goto('/publish');
1212+1313+ await expect(page.getByRole('heading', { name: 'Publish a thing' })).toBeVisible();
1414+ await expect(page.getByRole('heading', { name: 'Sign in to publish' })).toBeVisible();
1515+ const callout = page.locator('section[aria-label="Sign in to publish"]');
1616+ await expect(callout.getByRole('button', { name: 'Sign in' })).toBeVisible();
1717+1818+ // The signed-out surface must not expose the wizard stepper or publish action.
1919+ await expect(page.getByRole('navigation', { name: 'Publish steps' })).toHaveCount(0);
2020+ await expect(page.getByRole('button', { name: 'Publish to your PDS' })).toHaveCount(0);
2121+});
2222+2323+test('signed-out publish callout returns the user to /publish after sign-in', async ({ page }) => {
2424+ await page.goto('/publish');
2525+2626+ const returnTo = page.locator('section[aria-label="Sign in to publish"] input[name="return_to"]');
2727+ await expect(returnTo).toHaveValue('/publish');
2828+});
+24
migrations/005_drafts.sql
···11+-- PM-43: server-side, non-public publish drafts.
22+--
33+-- A draft is an in-progress publish composition that never reaches the actor's
44+-- PDS until publish. It is stored as a `DraftThingInput`-shaped JSON payload (a
55+-- ThingInput mirror with every field optional, so partial drafts round-trip).
66+-- Drafts are app-internal (not ATProto records, not federated) and are strictly
77+-- owner-scoped by DID. They live in their own table, so feeds — which read only
88+-- the `things` table — never surface them.
99+--
1010+-- `name` and `model_count` are denormalized copies refreshed on every upsert so
1111+-- `listDrafts` can render entries without parsing each payload.
1212+CREATE TABLE drafts (
1313+ owner_did TEXT NOT NULL,
1414+ draft_id TEXT NOT NULL,
1515+ payload_json TEXT NOT NULL,
1616+ name TEXT,
1717+ model_count INTEGER NOT NULL DEFAULT 0,
1818+ created_at INTEGER NOT NULL,
1919+ updated_at INTEGER NOT NULL,
2020+ PRIMARY KEY (owner_did, draft_id)
2121+);
2222+2323+CREATE INDEX idx_drafts_owner_updated
2424+ ON drafts(owner_did, updated_at DESC);
+475
src/appview/drafts.rs
···11+//! PM-43 server-side publish drafts + app-internal image upload.
22+//!
33+//! Drafts are app-internal (not ATProto records, not federated): owner-scoped
44+//! rows in the `drafts` table holding a [`DraftThingInput`] JSON payload. They
55+//! never reach the actor's PDS until publish, and they live in their own table
66+//! so feeds (which read only `things`) never surface them.
77+//!
88+//! Every handler authenticates with the strict OAuth session (same extractor as
99+//! the PM-28 writes) and scopes every query by the authenticated DID, so no
1010+//! actor can read, write, publish, or delete another actor's drafts.
1111+//!
1212+//! Routes (same-origin, cookie-authenticated; app-internal, not `/xrpc`):
1313+//! - `POST /app/drafts` create (server assigns an opaque id)
1414+//! - `PUT /app/drafts/{draft_id}` upsert an existing draft
1515+//! - `GET /app/drafts` list draft summaries
1616+//! - `GET /app/drafts/{draft_id}` fetch one draft payload
1717+//! - `DELETE /app/drafts/{draft_id}` delete a draft
1818+//! - `POST /app/drafts/{draft_id}/publish` assemble + publish + delete-on-success
1919+//! - `POST /app/images` upload an image blob, return an `#image`
2020+2121+use axum::Json;
2222+use axum::body::Body;
2323+use axum::extract::{Path, State};
2424+use axum::http::HeaderMap;
2525+use jacquard::client::Agent;
2626+use jacquard_axum::oauth::ExtractOAuthSession;
2727+use jacquard_common::types::blob::BlobRef;
2828+use jacquard_common::types::string::Did;
2929+use polymodel_api::space_polymodel::library::publish_thing::{PublishThingOutput, ThingInput};
3030+use polymodel_api::space_polymodel::library::{AspectRatio, Image};
3131+3232+use super::error::{AppResult, db, internal, invalid_request, not_found};
3333+use super::state::AppState;
3434+use super::writes::{
3535+ ExtractSession, agent_upload_blob, authenticated_did, content_type, ensure_polymodel_profile,
3636+ publish_composition,
3737+};
3838+use crate::publish::draft::{
3939+ DeleteDraftResponse, DraftIdResponse, DraftSummary, DraftThingInput, ImageUploadResponse,
4040+};
4141+4242+/// Image blobs are buffered fully (cover/preview art is small); cap to keep the
4343+/// app from buffering an unbounded body.
4444+const MAX_IMAGE_SIZE: usize = 20 * 1024 * 1024;
4545+4646+// ----------------------------------------------------------------------------
4747+// Owner-scoped DB helpers (pure; unit-tested directly)
4848+// ----------------------------------------------------------------------------
4949+5050+fn now_nanos() -> i64 {
5151+ chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
5252+}
5353+5454+/// Insert or update a draft row, refreshing the denormalized `name`/`model_count`
5555+/// and `updated_at`. `created_at` is preserved across updates.
5656+pub(super) async fn upsert_draft(
5757+ state: &AppState,
5858+ owner: &Did,
5959+ draft_id: &str,
6060+ draft: &DraftThingInput,
6161+) -> AppResult<()> {
6262+ let payload_json =
6363+ serde_json::to_string(draft).map_err(|e| internal(format!("draft serialize: {e}")))?;
6464+ let name = draft.display_name().map(ToOwned::to_owned);
6565+ let model_count = draft.model_count();
6666+ let now = now_nanos();
6767+ let owner_did = owner.as_ref();
6868+ db(sqlx::query!(
6969+ "INSERT INTO drafts (owner_did, draft_id, payload_json, name, model_count, created_at, updated_at) \
7070+ VALUES (?, ?, ?, ?, ?, ?, ?) \
7171+ ON CONFLICT(owner_did, draft_id) DO UPDATE SET \
7272+ payload_json = excluded.payload_json, \
7373+ name = excluded.name, \
7474+ model_count = excluded.model_count, \
7575+ updated_at = excluded.updated_at",
7676+ owner_did,
7777+ draft_id,
7878+ payload_json,
7979+ name,
8080+ model_count,
8181+ now,
8282+ now,
8383+ )
8484+ .execute(&state.pool)
8585+ .await)?;
8686+ Ok(())
8787+}
8888+8989+/// Load a single owner-scoped draft payload, if present.
9090+pub(super) async fn get_draft(
9191+ state: &AppState,
9292+ owner: &Did,
9393+ draft_id: &str,
9494+) -> AppResult<Option<DraftThingInput>> {
9595+ let owner_did = owner.as_ref();
9696+ let row = db(sqlx::query!(
9797+ "SELECT payload_json FROM drafts WHERE owner_did = ? AND draft_id = ?",
9898+ owner_did,
9999+ draft_id,
100100+ )
101101+ .fetch_optional(&state.pool)
102102+ .await)?;
103103+ match row {
104104+ Some(row) => {
105105+ let draft: DraftThingInput = serde_json::from_str(&row.payload_json)
106106+ .map_err(|e| internal(format!("draft deserialize: {e}")))?;
107107+ Ok(Some(draft))
108108+ }
109109+ None => Ok(None),
110110+ }
111111+}
112112+113113+/// List owner-scoped draft summaries, most-recently-updated first.
114114+pub(super) async fn list_drafts(state: &AppState, owner: &Did) -> AppResult<Vec<DraftSummary>> {
115115+ let owner_did = owner.as_ref();
116116+ let rows = db(sqlx::query!(
117117+ "SELECT draft_id, name, model_count, created_at, updated_at \
118118+ FROM drafts WHERE owner_did = ? ORDER BY updated_at DESC",
119119+ owner_did,
120120+ )
121121+ .fetch_all(&state.pool)
122122+ .await)?;
123123+ Ok(rows
124124+ .into_iter()
125125+ .map(|row| DraftSummary {
126126+ draft_id: row.draft_id,
127127+ name: row.name,
128128+ model_count: row.model_count,
129129+ created_at: row.created_at,
130130+ updated_at: row.updated_at,
131131+ })
132132+ .collect())
133133+}
134134+135135+/// Delete an owner-scoped draft; returns whether a row existed.
136136+pub(super) async fn delete_draft(state: &AppState, owner: &Did, draft_id: &str) -> AppResult<bool> {
137137+ let owner_did = owner.as_ref();
138138+ let result = db(sqlx::query!(
139139+ "DELETE FROM drafts WHERE owner_did = ? AND draft_id = ?",
140140+ owner_did,
141141+ draft_id,
142142+ )
143143+ .execute(&state.pool)
144144+ .await)?;
145145+ Ok(result.rows_affected() > 0)
146146+}
147147+148148+/// Promote a draft to a published thing: load → assemble → publish → delete.
149149+///
150150+/// The publish step is injected so the ordering invariant (the draft row is
151151+/// deleted **only after** a successful publish) is unit-testable without a live
152152+/// PDS agent. If `publish` returns `Err`, the draft is left untouched.
153153+pub(super) async fn promote_draft<F, Fut>(
154154+ state: &AppState,
155155+ owner: &Did,
156156+ draft_id: &str,
157157+ publish: F,
158158+) -> AppResult<PublishThingOutput>
159159+where
160160+ F: FnOnce(ThingInput) -> Fut,
161161+ Fut: std::future::Future<Output = AppResult<PublishThingOutput>>,
162162+{
163163+ let draft = get_draft(state, owner, draft_id)
164164+ .await?
165165+ .ok_or_else(not_found)?;
166166+ let thing = draft
167167+ .assemble()
168168+ .map_err(|errors| invalid_request(errors.to_string()))?;
169169+ let output = publish(thing).await?;
170170+ // Reached only on a successful publish; a failure short-circuits above and
171171+ // leaves the draft recoverable.
172172+ delete_draft(state, owner, draft_id).await?;
173173+ Ok(output)
174174+}
175175+176176+// ----------------------------------------------------------------------------
177177+// Handlers
178178+// ----------------------------------------------------------------------------
179179+180180+pub(super) async fn create_draft(
181181+ State(state): State<AppState>,
182182+ ExtractOAuthSession(session): ExtractSession,
183183+ Json(draft): Json<DraftThingInput>,
184184+) -> AppResult<Json<DraftIdResponse>> {
185185+ let agent = Agent::from(session);
186186+ let actor = authenticated_did(&agent).await?;
187187+ let draft_id = ulid::Ulid::new().to_string();
188188+ upsert_draft(&state, &actor, &draft_id, &draft).await?;
189189+ Ok(Json(DraftIdResponse { draft_id }))
190190+}
191191+192192+pub(super) async fn put_draft(
193193+ State(state): State<AppState>,
194194+ ExtractOAuthSession(session): ExtractSession,
195195+ Path(draft_id): Path<String>,
196196+ Json(draft): Json<DraftThingInput>,
197197+) -> AppResult<Json<DraftIdResponse>> {
198198+ let agent = Agent::from(session);
199199+ let actor = authenticated_did(&agent).await?;
200200+ upsert_draft(&state, &actor, &draft_id, &draft).await?;
201201+ Ok(Json(DraftIdResponse { draft_id }))
202202+}
203203+204204+pub(super) async fn get_draft_handler(
205205+ State(state): State<AppState>,
206206+ ExtractOAuthSession(session): ExtractSession,
207207+ Path(draft_id): Path<String>,
208208+) -> AppResult<Json<DraftThingInput>> {
209209+ let agent = Agent::from(session);
210210+ let actor = authenticated_did(&agent).await?;
211211+ let draft = get_draft(&state, &actor, &draft_id)
212212+ .await?
213213+ .ok_or_else(not_found)?;
214214+ Ok(Json(draft))
215215+}
216216+217217+pub(super) async fn list_drafts_handler(
218218+ State(state): State<AppState>,
219219+ ExtractOAuthSession(session): ExtractSession,
220220+) -> AppResult<Json<Vec<DraftSummary>>> {
221221+ let agent = Agent::from(session);
222222+ let actor = authenticated_did(&agent).await?;
223223+ let drafts = list_drafts(&state, &actor).await?;
224224+ Ok(Json(drafts))
225225+}
226226+227227+pub(super) async fn delete_draft_handler(
228228+ State(state): State<AppState>,
229229+ ExtractOAuthSession(session): ExtractSession,
230230+ Path(draft_id): Path<String>,
231231+) -> AppResult<Json<DeleteDraftResponse>> {
232232+ let agent = Agent::from(session);
233233+ let actor = authenticated_did(&agent).await?;
234234+ let deleted = delete_draft(&state, &actor, &draft_id).await?;
235235+ Ok(Json(DeleteDraftResponse { deleted }))
236236+}
237237+238238+pub(super) async fn publish_draft(
239239+ State(state): State<AppState>,
240240+ ExtractOAuthSession(session): ExtractSession,
241241+ Path(draft_id): Path<String>,
242242+) -> AppResult<Json<PublishThingOutput>> {
243243+ let agent = Agent::from(session);
244244+ let actor = authenticated_did(&agent).await?;
245245+ let _ = ensure_polymodel_profile(&state, &agent, &actor, false).await?;
246246+ let _write_guard = state.write_lock.lock().await;
247247+ let output = promote_draft(&state, &actor, &draft_id, |thing| {
248248+ publish_composition(&state, &agent, &actor, thing)
249249+ })
250250+ .await?;
251251+ Ok(Json(output))
252252+}
253253+254254+pub(super) async fn upload_image(
255255+ State(state): State<AppState>,
256256+ ExtractOAuthSession(session): ExtractSession,
257257+ headers: HeaderMap,
258258+ body: Body,
259259+) -> AppResult<Json<ImageUploadResponse>> {
260260+ let agent = Agent::from(session);
261261+ let actor = authenticated_did(&agent).await?;
262262+ let _ = ensure_polymodel_profile(&state, &agent, &actor, false).await?;
263263+264264+ let mime_type = content_type(&headers);
265265+ let alt = headers
266266+ .get("x-polymodel-alt")
267267+ .and_then(|v| v.to_str().ok())
268268+ .unwrap_or("")
269269+ .to_string();
270270+271271+ let bytes = axum::body::to_bytes(body, MAX_IMAGE_SIZE)
272272+ .await
273273+ .map_err(|_| invalid_request("image body exceeds the size limit or could not be read"))?;
274274+ if bytes.is_empty() {
275275+ return Err(invalid_request(
276276+ "image upload requires a non-empty raw byte body",
277277+ ));
278278+ }
279279+280280+ // `aspectRatio` is required by `#image` but is not returned by uploadBlob, so
281281+ // decode width/height from the bytes (header-only probe, no full decode).
282282+ let dims = imagesize::blob_size(&bytes)
283283+ .map_err(|e| invalid_request(format!("unsupported or corrupt image: {e}")))?;
284284+285285+ let blob = agent_upload_blob(&agent, bytes.to_vec(), &mime_type).await?;
286286+ let image = Image {
287287+ alt: alt.into(),
288288+ aspect_ratio: AspectRatio {
289289+ height: dims.height as i64,
290290+ width: dims.width as i64,
291291+ extra_data: None,
292292+ },
293293+ image: BlobRef::from(blob),
294294+ extra_data: None,
295295+ };
296296+ Ok(Json(ImageUploadResponse { image }))
297297+}
298298+299299+#[cfg(test)]
300300+mod tests {
301301+ use std::str::FromStr;
302302+303303+ use jacquard_common::types::string::Did;
304304+ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
305305+306306+ use super::*;
307307+ use crate::publish::draft::{DraftModelInput, DraftPartInput};
308308+309309+ const DID_A: &str = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa";
310310+ const DID_B: &str = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb";
311311+312312+ async fn state() -> AppState {
313313+ let options = SqliteConnectOptions::from_str("sqlite::memory:").unwrap();
314314+ let pool = SqlitePoolOptions::new()
315315+ .max_connections(1)
316316+ .connect_with(options)
317317+ .await
318318+ .unwrap();
319319+ sqlx::migrate!("./migrations").run(&pool).await.unwrap();
320320+ let bootstrap = crate::oauth::bootstrap_oauth(pool.clone(), Some("http://localhost"))
321321+ .expect("ephemeral OAuth bootstrap for tests");
322322+ AppState::new(pool, bootstrap)
323323+ }
324324+325325+ fn did(s: &str) -> Did {
326326+ Did::new_owned(s).unwrap()
327327+ }
328328+329329+ fn draft_named(name: &str) -> DraftThingInput {
330330+ DraftThingInput {
331331+ name: Some(name.to_string()),
332332+ license: Some("CC-BY-4.0".to_string()),
333333+ models: vec![DraftModelInput {
334334+ name: Some("Model".to_string()),
335335+ parts: vec![DraftPartInput {
336336+ name: Some("part.stl".to_string()),
337337+ upload_id: Some("abcdef0123456789-42".to_string()),
338338+ ..Default::default()
339339+ }],
340340+ ..Default::default()
341341+ }],
342342+ ..Default::default()
343343+ }
344344+ }
345345+346346+ #[tokio::test]
347347+ async fn upsert_and_get_round_trips_owner_scoped() {
348348+ let state = state().await;
349349+ let owner = did(DID_A);
350350+ let draft = draft_named("Widget");
351351+ upsert_draft(&state, &owner, "d1", &draft).await.unwrap();
352352+353353+ let loaded = get_draft(&state, &owner, "d1").await.unwrap();
354354+ assert_eq!(loaded, Some(draft));
355355+356356+ // A different owner cannot see it.
357357+ let other = get_draft(&state, &did(DID_B), "d1").await.unwrap();
358358+ assert_eq!(other, None);
359359+ }
360360+361361+ #[tokio::test]
362362+ async fn upsert_updates_denormalized_summary_and_preserves_created_at() {
363363+ let state = state().await;
364364+ let owner = did(DID_A);
365365+ upsert_draft(&state, &owner, "d1", &draft_named("First"))
366366+ .await
367367+ .unwrap();
368368+ let created_at: i64 =
369369+ sqlx::query_scalar("SELECT created_at FROM drafts WHERE draft_id = 'd1'")
370370+ .fetch_one(&state.pool)
371371+ .await
372372+ .unwrap();
373373+374374+ upsert_draft(&state, &owner, "d1", &draft_named("Second"))
375375+ .await
376376+ .unwrap();
377377+ let summaries = list_drafts(&state, &owner).await.unwrap();
378378+ assert_eq!(summaries.len(), 1);
379379+ assert_eq!(summaries[0].name.as_deref(), Some("Second"));
380380+ assert_eq!(summaries[0].model_count, 1);
381381+382382+ let created_after: i64 =
383383+ sqlx::query_scalar("SELECT created_at FROM drafts WHERE draft_id = 'd1'")
384384+ .fetch_one(&state.pool)
385385+ .await
386386+ .unwrap();
387387+ assert_eq!(
388388+ created_at, created_after,
389389+ "created_at must be preserved on update"
390390+ );
391391+ }
392392+393393+ #[tokio::test]
394394+ async fn list_is_owner_scoped_and_ordered() {
395395+ let state = state().await;
396396+ let a = did(DID_A);
397397+ upsert_draft(&state, &a, "d1", &draft_named("One"))
398398+ .await
399399+ .unwrap();
400400+ upsert_draft(&state, &a, "d2", &draft_named("Two"))
401401+ .await
402402+ .unwrap();
403403+ upsert_draft(&state, &did(DID_B), "d3", &draft_named("Other"))
404404+ .await
405405+ .unwrap();
406406+407407+ let summaries = list_drafts(&state, &a).await.unwrap();
408408+ assert_eq!(summaries.len(), 2);
409409+ // Most recently updated first.
410410+ assert_eq!(summaries[0].draft_id, "d2");
411411+ assert_eq!(summaries[1].draft_id, "d1");
412412+ }
413413+414414+ #[tokio::test]
415415+ async fn delete_is_owner_scoped() {
416416+ let state = state().await;
417417+ let a = did(DID_A);
418418+ upsert_draft(&state, &a, "d1", &draft_named("One"))
419419+ .await
420420+ .unwrap();
421421+422422+ // Another owner cannot delete it.
423423+ assert!(!delete_draft(&state, &did(DID_B), "d1").await.unwrap());
424424+ assert!(get_draft(&state, &a, "d1").await.unwrap().is_some());
425425+426426+ // The owner can.
427427+ assert!(delete_draft(&state, &a, "d1").await.unwrap());
428428+ assert!(get_draft(&state, &a, "d1").await.unwrap().is_none());
429429+ // Deleting again reports no row.
430430+ assert!(!delete_draft(&state, &a, "d1").await.unwrap());
431431+ }
432432+433433+ #[tokio::test]
434434+ async fn promote_failure_keeps_draft() {
435435+ let state = state().await;
436436+ let owner = did(DID_A);
437437+ upsert_draft(&state, &owner, "d1", &draft_named("Keepme"))
438438+ .await
439439+ .unwrap();
440440+441441+ let result = promote_draft(&state, &owner, "d1", |_thing| async {
442442+ Err(internal("simulated publish failure"))
443443+ })
444444+ .await;
445445+ assert!(result.is_err(), "publish failure must propagate");
446446+447447+ // The invariant: a failed publish must not delete the draft.
448448+ assert!(
449449+ get_draft(&state, &owner, "d1").await.unwrap().is_some(),
450450+ "draft must survive a failed publish"
451451+ );
452452+ }
453453+454454+ #[tokio::test]
455455+ async fn promote_invalid_draft_is_rejected_and_kept() {
456456+ let state = state().await;
457457+ let owner = did(DID_A);
458458+ // Missing license/models → assemble fails before any publish attempt.
459459+ let partial = DraftThingInput {
460460+ name: Some("WIP".to_string()),
461461+ ..Default::default()
462462+ };
463463+ upsert_draft(&state, &owner, "d1", &partial).await.unwrap();
464464+465465+ let mut published = false;
466466+ let result = promote_draft(&state, &owner, "d1", |_thing| {
467467+ published = true;
468468+ async { unreachable!("publish must not run for an invalid draft") }
469469+ })
470470+ .await;
471471+ assert!(result.is_err());
472472+ assert!(!published, "an invalid draft must never reach publish");
473473+ assert!(get_draft(&state, &owner, "d1").await.unwrap().is_some());
474474+ }
475475+}
+19
src/appview/mod.rs
···2020pub mod views;
21212222mod actor;
2323+mod drafts;
2324mod graph;
2425mod library;
2526mod proxy;
···115116 GetSessionRequest::PATH,
116117 axum::routing::get(actor::get_session),
117118 )
119119+ // PM-43 app-internal draft store + image upload. Not federated lexicons,
120120+ // so these use a plain `/app/*` namespace (cookie-authenticated,
121121+ // same-origin) rather than `/xrpc/space.polymodel.*`.
122122+ .route(
123123+ "/app/drafts",
124124+ axum::routing::post(drafts::create_draft).get(drafts::list_drafts_handler),
125125+ )
126126+ .route(
127127+ "/app/drafts/{draft_id}",
128128+ axum::routing::put(drafts::put_draft)
129129+ .get(drafts::get_draft_handler)
130130+ .delete(drafts::delete_draft_handler),
131131+ )
132132+ .route(
133133+ "/app/drafts/{draft_id}/publish",
134134+ axum::routing::post(drafts::publish_draft),
135135+ )
136136+ .route("/app/images", axum::routing::post(drafts::upload_image))
118137 // Per-request timing: logs total server-side handling time for every
119138 // appview request, *including* the OAuth session-restore extractor that
120139 // runs before each handler body. Use this to localize latency (server