You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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).
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).
— 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
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.
Frontend rewires uploadAttachment2 to call invoke('uploadDiagramAttachment', { pageId, attachmentName, hash, pngBase64 }) instead of going through @forge/bridge.requestConfluence directly.
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
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)
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.
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 callsapi.asApp().requestConfluence(...). The current design causes ~13.8% ofattachment_upload_failedevents to behttp_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_failedbyevent_label:TypeError(regression inv2026.05.280526-lite, unrelated, tracked in #162)http_403non_error_thrownhttp_404http_400Error,SyntaxError,http_503,UnknownErrorExcluding the
TypeErrorregression,http_403is the largest non-noise category at ~36% of "real" upload failures. Distinct fromhttp_404(page or content gone — different fix).Current architecture
Frontend path:
src/forge-embed-viewer.ts:54→createAttachmentIfContentChanged(src/model/Attachment.ts:380) →uploadAttachment2→makeRequest(src/model/Attachment.ts:82) →@forge/bridge.requestConfluencewithmultipart/form-data.requestConfluencefrom@forge/bridgeruns with the viewing user's Confluence credentials. There is noasApp()/asUser()selector on the frontend bridge — that's a backend-only construct (@forge/api).The existing "gate" in
forge-embed-viewer.ts:57—— is not actually gating anything:
canUserEdit()in ApWrapper2.ts:1230 is areturn truestub with aTODO: check if the user has edit permission via Forge API. Every viewer attempts the upload.The app already declares
write:attachment:confluencein manifest.yml. Running as the app principal would succeed where the user fails.Proposed fix
uploadDiagramAttachment(pageId, attachmentName, hash, pngBase64)that usesapi.asApp().requestConfluence(...)to POST the multipart form. The resolver lives alongside the existingsrc/export.jsbackend handler.uploadAttachment2to callinvoke('uploadDiagramAttachment', { pageId, attachmentName, hash, pngBase64 })instead of going through@forge/bridge.requestConfluencedirectly.attachment_upload_succeeded/_failed/_skippedcontinue 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 existingAttachmentUploadHttpErrorpath, sohttp_<status>labels still apply if the app itself can't write (rare — should drop to near-zero).manifest.ymlgets afunctionentry foruploadDiagramAttachmentplus the existingpermissions:content:write/write:attachment:confluencescopes that are already declared.Expected impact:
http_403share ofattachment_upload_failedshould drop to near-zero, which should drop themacro_export_failed → attachment_not_foundrate 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:
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 besideexport.jssrc/forge-resolvers/upload-attachment.js— new directorysrc/export.jsas an additional exportRecommendation:
src/upload-attachment.js— symmetric withsrc/export.js, easy to find.Implementation sketch
Estimated PR size: ~360 lines changed, focused on
Attachment.ts+ resolver. No UI changes.Test plan
pnpm test:unit)attachment_upload_failed → event_label = http_403share drops to <1% within 7dmacro_export_failed → failure_reason = attachment_not_foundrate 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
http_<status>labels that revealedhttp_403as the dominant real causeattachment_upload_succeededfor the denominatorTypeErrorspike inv2026.05.280526-lite); unrelated to this workAgent-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.