From 2843469e0479898fd36dc3f6ccbc157b6b2d90b3 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 28 Sep 2026 14:39:49 +0000 Subject: [PATCH] =?UTF-8?q?contact=20messages:=20client=20proof=20flow,=20?= =?UTF-8?q?upload=20re-auth,=20trail=20=E2=9C=89=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contact bundle submits each solved challenge immediately and treats {quota} frames as the grant that releases queued sends/uploads; an upload answered 429 "pow" re-registers for a fresh challenge and retries once it lands (Composer's reauthorize prop). The contact email frame fills the dialog's mailto link only after a solve. Visit trails render the ✉️ marker on the step where the panel was opened and the proof solved. --- frontend/src/Composer.vue | 11 +++- frontend/src/ContactOverlay.vue | 10 +++- frontend/src/ContactPanel.vue | 11 +++- frontend/src/TrailLink.vue | 10 ++++ frontend/src/analytics/format.js | 3 + frontend/src/contact-main.js | 95 ++++++++++++++++++++------------ frontend/src/pagerite.js | 5 +- 7 files changed, 105 insertions(+), 40 deletions(-) diff --git a/frontend/src/Composer.vue b/frontend/src/Composer.vue index 32f32b9..f052d0c 100644 --- a/frontend/src/Composer.vue +++ b/frontend/src/Composer.vue @@ -18,6 +18,9 @@ const props = defineProps({ // kept — e.g. the contact panel's first-message name/email fields are // still empty. sendDisabled: { type: Boolean, default: false }, + // Visitor side: an upload hit the end of the PoW quota (429 "pow") — + // re-prove and resolve, then the upload retries once. + reauthorize: { type: Function, default: null }, }) const emit = defineEmits(['send']) @@ -86,7 +89,13 @@ async function uploadFiles(files) { const path = `/_${props.token ? 'contact' : 'api'}/files/${encodeURIComponent(file.name)}` const init = { method: 'PUT', body: file } if (props.token) init.headers = { 'x-contact-token': props.token } - const r = await (props.token ? fetch(path, init) : apiFetch(path, init)) + const put = () => (props.token ? fetch(path, init) : apiFetch(path, init)) + let r = await put() + if (!r.ok && r.status === 429 && props.reauthorize) { + // The PoW quota (shared with messages) ran out: re-prove, retry once. + const detail = await r.json().then((d) => d?.detail, () => null) + if (detail === 'pow' && (await props.reauthorize())) r = await put() + } if (!r.ok) { const detail = await r.json().then((d) => d?.detail, () => null) throw new Error(detail || `status ${r.status}`) diff --git a/frontend/src/ContactOverlay.vue b/frontend/src/ContactOverlay.vue index 55065a2..bdc35b5 100644 --- a/frontend/src/ContactOverlay.vue +++ b/frontend/src/ContactOverlay.vue @@ -12,6 +12,8 @@ import MessagesView from './MessagesView.vue' const props = defineProps({ state: { type: Object, required: true }, token: { type: String, required: true }, + // Visitor-side PoW quota re-grant for the composer's upload retries. + reauthorize: { type: Function, default: null }, }) const emit = defineEmits(['send', 'close']) @@ -45,7 +47,13 @@ onUnmounted(() => removeEventListener('keydown', onKey)) {{ state.email }} - + diff --git a/frontend/src/ContactPanel.vue b/frontend/src/ContactPanel.vue index 12be646..76380e0 100644 --- a/frontend/src/ContactPanel.vue +++ b/frontend/src/ContactPanel.vue @@ -11,6 +11,9 @@ import Composer from './Composer.vue' const props = defineProps({ state: { type: Object, required: true }, token: { type: String, required: true }, + // Re-grant the PoW quota (an upload hit its end): resolves once the + // fresh proof is accepted, so the upload can retry. + reauthorize: { type: Function, default: null }, }) const emit = defineEmits(['send']) @@ -81,7 +84,13 @@ function onSend(md) { - +