Skip to content

Move attachment upload to backend resolver with api.asApp() to fix viewer-triggered 403s #166

@whimet

Description

@whimet

Summary

Move the diagram-PNG attachment upload from the Forge Custom UI (@forge/bridge.requestConfluence, runs as the viewing user) into a backend Forge resolver function that calls api.asApp().requestConfluence(...). The current design causes ~13.8% of attachment_upload_failed events to be http_403 — viewer-only users triggering uploads that 403 because they lack attachment-write permission on the page, even though the app itself does.

Downstream impact: those silently failed uploads are the upstream of 94% of macro_export_failed → attachment_not_found (3-day post-instrumentation slice; n=735/3d). Users see "Diagram image not available for export" (PR #165) and can't fix it.

Evidence

Mixpanel slices for the 3-day window after PR #128 / #129 shipped:

attachment_upload_failed by event_label:

Label Count %
TypeError (regression in v2026.05.280526-lite, unrelated, tracked in #162) 3,890 61.7%
http_403 871 13.8%
non_error_thrown 852 13.5%
http_404 589 9.3%
http_400 43 0.7%
Error, SyntaxError, http_503, UnknownError 57 0.9%

Excluding the TypeError regression, http_403 is the largest non-noise category at ~36% of "real" upload failures. Distinct from http_404 (page or content gone — different fix).

Current architecture

Frontend path: src/forge-embed-viewer.ts:54createAttachmentIfContentChanged (src/model/Attachment.ts:380) → uploadAttachment2makeRequest (src/model/Attachment.ts:82) → @forge/bridge.requestConfluence with multipart/form-data.

requestConfluence from @forge/bridge runs with the viewing user's Confluence credentials. There is no asApp() / asUser() selector on the frontend bridge — that's a backend-only construct (@forge/api).

The existing "gate" in forge-embed-viewer.ts:57

if(globals.apWrapper.isDisplayMode() && await globals.apWrapper.canUserEdit()) { ... }

— is not actually gating anything: canUserEdit() in ApWrapper2.ts:1230 is a return true stub with a TODO: check if the user has edit permission via Forge API. Every viewer attempts the upload.

The app already declares write:attachment:confluence in manifest.yml. Running as the app principal would succeed where the user fails.

Proposed fix

  1. New Forge resolver function uploadDiagramAttachment(pageId, attachmentName, hash, pngBase64) that uses api.asApp().requestConfluence(...) to POST the multipart form. The resolver lives alongside the existing src/export.js backend handler.
  2. Frontend rewires uploadAttachment2 to call invoke('uploadDiagramAttachment', { pageId, attachmentName, hash, pngBase64 }) instead of going through @forge/bridge.requestConfluence directly.
  3. Same analytics events keep firing on the frontendattachment_upload_succeeded / _failed / _skipped continue to wrap the call, so the dashboards built on top of fix(analytics): label attachment_upload_failed by HTTP status #128 / feat(analytics): emit attachment_upload_succeeded for upload denominator #129 keep working unchanged. The resolver's HTTP-status response replaces the bridge response in the existing AttachmentUploadHttpError path, so http_<status> labels still apply if the app itself can't write (rare — should drop to near-zero).
  4. manifest.yml gets a function entry for uploadDiagramAttachment plus the existing permissions:content:write / write:attachment:confluence scopes that are already declared.

Expected impact: http_403 share of attachment_upload_failed should drop to near-zero, which should drop the macro_export_failed → attachment_not_found rate from ~20% to something materially lower. We'll know post-deploy by querying the same Mixpanel slices.

Open design decisions (need product sign-off before implementing)

These are the reason this is an issue and not a PR.

1. Should viewer-only users trigger writes?

After this change, any user who can view a page will silently cause an attachment write on that page, because the app does it on their behalf using app-level scope. That's a meaningful policy change — current Confluence behaviour (and the v1 attachment API's default) ties attachment-write to page-edit permission.

Options:

Option Pro Con
A. Always upload as app Maximally reliable; fixes the user-pain Viewer-only users write to pages they can't edit; could surprise admins on locked-down spaces
B. Upload as app only when user can't (try-user-first, fallback-to-app) Preserves current write-permission semantics for editors Adds latency to editor uploads (one wasted try); doubles network volume
C. Upload as app, but only if the page has no current PNG attachment Limits the policy change to bootstrapping new diagrams Once the attachment exists, updates still fail for viewers — partial fix
D. Upload as app + admin-toggleable in app settings Lets locked-down sites opt out Settings UI work; defaults question

Recommendation: A. The attachment is a derived artifact (a screenshot of the diagram the user is currently viewing), not user-authored content. The user already has read access — them causing a render artifact to be cached server-side is similar to a thumbnail being generated. Worth a quick legal/product check though.

2. Should we keep the existing frontend path for editors?

Keeping editor uploads on the frontend has zero benefit once the resolver exists, and adds two code paths to maintain. Recommendation: drop the frontend path entirely once the resolver lands.

3. PNG transport encoding

Forge invoke() payloads are JSON. A ~10-50 KB PNG fits comfortably in a JSON string as base64 (~13-67 KB). No streaming needed. Recommendation: base64 in the invoke payload.

4. Resolver location & naming

Existing backend handlers live at src/export.js. The resolver could go in:

  • src/upload-attachment.js — new top-level handler beside export.js
  • src/forge-resolvers/upload-attachment.js — new directory
  • Inside existing src/export.js as an additional export

Recommendation: src/upload-attachment.js — symmetric with src/export.js, easy to find.

Implementation sketch

src/upload-attachment.js               (~120 lines, new)
  - handler({ pageId, attachmentName, hash, pngBase64 })
  - decodes base64, constructs multipart FormData
  - api.asApp().requestConfluence(...) the upload
  - returns { attachmentId, versionNumber } or { error: { status, body } }

src/model/Attachment.ts                (~50 lines changed)
  - uploadAttachment2 → invoke('uploadAttachment', {...})
  - drop direct @forge/bridge.requestConfluence multipart path
  - keep all existing analytics — the new failure shapes flow into
    AttachmentUploadHttpError the same way

manifest.yml                           (~10 lines added)
  - function entry for uploadAttachment
  - confirm permissions:content:write already declared

src/model/Attachment.spec.ts           (~80 lines changed/added)
  - update mocks to expect invoke() instead of requestConfluence
  - add tests for resolver-side error shapes (404 from app, etc.)

src/upload-attachment.spec.js          (~100 lines, new)
  - resolver round-trip: success, 4xx, 5xx, malformed payload

Estimated PR size: ~360 lines changed, focused on Attachment.ts + resolver. No UI changes.

Test plan

  • Unit tests pass (pnpm test:unit)
  • E2E test creates a diagram as user A (page editor), exports as user B (viewer-only on the page) — verify export succeeds (today, the page-editor upload succeeds; verifying user B's view-driven upload doesn't break the editor's work is the key new scenario)
  • Post-deploy: attachment_upload_failed → event_label = http_403 share drops to <1% within 7d
  • Post-deploy: macro_export_failed → failure_reason = attachment_not_found rate drops noticeably (currently 94% of fails; expect at least the 13.8%-share-of-failed-uploads worth of pages to start succeeding once their viewer-driven uploads succeed)

Related

Agent-readiness

Ready for human review of the four open design decisions above before agent implementation. Once decisions land, implementation is mechanical and small enough for one PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingenhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions