From 37e3bfc2c73b58358c0a79ef5e14bc90a72143f0 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 20:37:49 +0100 Subject: [PATCH] Recover original attachments through the confirmed Bubble modal Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a192e9-c28b-7533-bf25-e2341364c22a --- ...bubble-attachment-recovery-2026-10-31.json | 41 +++++++++ docs/usecases/sharing-portability-review.md | 17 ++++ scripts/bubble-resource-utils.mjs | 25 +++++ scripts/capture-bubble-attachments.mjs | 91 +++++++++++++++++++ scripts/tests/bubble-resource-utils.test.mjs | 42 ++++++++- .../CSOC-WP-0013-sharing-content-hardening.md | 17 ++++ ...-WP-0014-cohort-transfer-bubble-offramp.md | 20 +++- 7 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 docs/release/bubble-attachment-recovery-2026-10-31.json create mode 100644 scripts/capture-bubble-attachments.mjs diff --git a/docs/release/bubble-attachment-recovery-2026-10-31.json b/docs/release/bubble-attachment-recovery-2026-10-31.json new file mode 100644 index 0000000..04ec264 --- /dev/null +++ b/docs/release/bubble-attachment-recovery-2026-10-31.json @@ -0,0 +1,41 @@ +{ + "schema": "coulomb.bubble-attachment-recovery.v1", + "completed_at": "2026-10-31T19:35:10.719868+00:00", + "source_articles_sha256": "f33967c79c0f8a60a7d504f5d5e4082f8bc9e3c34f6a3160cb200fd380831d62", + "capture_sha256": "995fa2558bdfab1035e538152948843977e66e096b286cc762b45a232b0c0052", + "visited_pages": 6, + "previously_flagged_pages": 5, + "attachment_relations": 2, + "retained_files": 2, + "retained_bytes": 17529, + "all_ui_and_independent_download_bytes_match": true, + "original_five_parent_bodies_and_dates_unchanged": true, + "source_attachment_dates_retained": true, + "mapped_draft_pages": 1, + "example_parents_outside_original_capture": 1, + "new_attachment_child_records": 2, + "four_scrap_relations_resolved": false, + "original_bundle_files_preserved_in_place": 739, + "draft_body_and_existing_metadata_unchanged": true, + "source_resource_blockers_removed": false, + "resource_completeness_verified": false, + "production_imported": false, + "source_mutation_controls_activated": false, + "private_review_path": "/home/worsch/.config/coulomb-social/auth/exports/attachment-bundle-20261031/attachments-index.html", + "preparation_manifest_sha256": "f61f4dc48c4dcc66aa967df1bbf274f1027630a4417a82d249d04bb6b6b73184", + "validation": { + "full_gate": "TMPDIR=/var/tmp make check", + "passed": 670, + "errors": 0, + "failures": 0, + "skipped": 0, + "synthetic_resource_guards": 7, + "full_gate_log_sha256": "6195e8cd91bb2dca72e240159a51d4ced15c979f89f3909c9495b4b8874f6876" + }, + "implementation_sha256": { + "scripts/capture-bubble-attachments.mjs": "cf8cba78d3a80f07f3448a50fb74d9502a01b84ff8be880c4d9e2a697af22812", + "scripts/bubble-resource-utils.mjs": "4f4e125f4d48ea5d8fb6421fb570d04a09fd2668e42c4c4857d4ac3a211e89b0", + "scripts/tests/bubble-resource-utils.test.mjs": "e434f56d9c586195f11ec1c642ef3f4da41d7cd2d4c01fc44f066bd3f76db192" + }, + "private_preparation_driver_sha256": "e74d9571a6962e095bd299f15c47aa7a19f3223183f722a98a34a10450c29861" +} diff --git a/docs/usecases/sharing-portability-review.md b/docs/usecases/sharing-portability-review.md index 61be55b..6a74a06 100644 --- a/docs/usecases/sharing-portability-review.md +++ b/docs/usecases/sharing-portability-review.md @@ -187,3 +187,20 @@ present in raw source but omitted from the prior review flags. They must be captured or given an explicit disposition before migration can be accepted. Private supplemental report: `~/.config/coulomb-social/auth/exports/body-resources-20261031.json`. Public proof: `../release/bubble-body-resources-2026-10-31.json`. + + +### Confirmed attachment modal and recovered files + +The user clarified that Bubble’s Add attachment image opens the modal used for +both adding and downloading files. The bounded read used only that opener and +Open file. Two files are retained, with exact matches between UI downloads and +independent CDN fetches. Original file-child creation/modification dates and +relations are retained; the existing matched draft includes its attachment and +provenance without changing its body or existing dates. The example outside the +original capture remains separately retained for reconciliation. + +Private review/downloads: +`~/.config/coulomb-social/auth/exports/attachment-bundle-20261031/attachments-index.html`. +The four separate scrap relations remain unresolved. Empty attachment modals do +not establish absence of scraps. No production import or productive acceptance +is inferred. Evidence: ../release/bubble-attachment-recovery-2026-10-31.json. diff --git a/scripts/bubble-resource-utils.mjs b/scripts/bubble-resource-utils.mjs index 0ab390b..63fc0bd 100644 --- a/scripts/bubble-resource-utils.mjs +++ b/scripts/bubble-resource-utils.mjs @@ -4,6 +4,31 @@ import {normalizeBubbleRef} from './bubble-corpus-utils.mjs'; export const RESOURCE_TYPES = new Set(['custom.scrap', 'custom.relattachment', 'custom.attachment']); const validId = value => typeof value === 'string' && /^[0-9]+x[0-9]+$/.test(value); +/** Bind observed attachment relations to child articles and their original files. */ +export function attachmentRows(parent, resources, articles) { + const rows=[]; + for (const [key,versions] of Object.entries(resources.records)) { + if (!key.startsWith('custom.relattachment:')) continue; + if (!versions.some(r=>normalizeBubbleRef(r.to_custom_article)===parent)) continue; + if (versions.length!==1) throw new Error('attachment_relation_drift'); + const relation=versions[0], id=normalizeBubbleRef(relation._chunk_custom_article); + const child=articles[id]; + if (!validId(id)||!child||normalizeBubbleRef(child.context_custom_article)!==parent) + throw new Error('attachment_child_binding'); + const source=child.logo_image; + if (typeof source!=='string') throw new Error('attachment_file_missing'); + const url=new URL(source.startsWith('//')?'https:'+source:source); + if (url.protocol!=='https:'||url.username||url.password||url.port||url.hash|| + !/^[a-f0-9]{32}\.cdn\.bubble\.io$/.test(url.hostname)) + throw new Error('attachment_file_origin'); + rows.push({relation_id:normalizeBubbleRef(relation._id),article_id:id, + url:url.href,name:child.nomer_text||'',created_at:child['Created Date'], + modified_at:child['Modified Date']}); + } + if (rows.length>100) throw new Error('attachment_count_bound'); + return rows; +} + export function resourceParents(articles) { return Object.entries(articles).filter(([,a]) => a.hasattachments_boolean === true || (Object.hasOwn(a, '_list_custom_scrap') && diff --git a/scripts/capture-bubble-attachments.mjs b/scripts/capture-bubble-attachments.mjs new file mode 100644 index 0000000..1ae0560 --- /dev/null +++ b/scripts/capture-bubble-attachments.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +/** Recover existing files through the user-confirmed Bubble attachment modal. */ +import {chromium} from 'playwright'; +import {readFileSync,mkdirSync,writeFileSync,lstatSync,openSync,writeSync,fsyncSync, + closeSync,renameSync,unlinkSync} from 'node:fs'; +import {resolve,dirname,join} from 'node:path'; +import {homedir} from 'node:os'; +import {fileURLToPath} from 'node:url'; +import {createHash} from 'node:crypto'; +import {parseArgs} from 'node:util'; +import {assertSessionFresh,isCorpusResponse,boundedCaptureBody,ingestCapturedBody, + normalizeBubbleRef} from './bubble-corpus-utils.mjs'; +import {resourceParents,ingestResourceBody,attachmentRows} from './bubble-resource-utils.mjs'; +const ROOT=resolve(dirname(fileURLToPath(import.meta.url)),'..'),BASE='https://coulomb.social'; +const MAX_FILE=64*1024*1024,MAX_TOTAL=512*1024*1024; +const sha=raw=>createHash('sha256').update(raw).digest('hex'); +const {values}=parseArgs({options:{articles:{type:'string'},output:{type:'string'}, + 'expect-articles-sha256':{type:'string'},'example-article':{type:'string'}}}); +function noLinks(path){for(let p=path;;p=dirname(p)){if(lstatSync(p).isSymbolicLink())throw Error('symlink');if(dirname(p)===p)break;}} +function privateRead(path){noLinks(path);const s=lstatSync(path);if(!s.isFile()||s.uid!==process.getuid()||(s.mode&0o077)||s.nlink!==1||s.size>10000000)throw Error('private_input');return readFileSync(path);} +let browser,total=0; +try{ + if(!values.articles||!values.output||!/^[a-f0-9]{64}$/.test(values['expect-articles-sha256']||''))throw Error('source_binding'); + const source=resolve(values.articles),out=resolve(values.output),raw=privateRead(source); + if(sha(raw)!==values['expect-articles-sha256'])throw Error('source_drift'); + const example=values['example-article'];if(example&&!/^\d+x\d+$/.test(example))throw Error('example_id'); + const parents=[...new Set([...(example?[example]:[]),...resourceParents(JSON.parse(raw))])]; + if(!parents.length||parents.length>10)throw Error('target_bound'); + if(out===ROOT||out.startsWith(ROOT+'/')||out.startsWith(dirname(source)+'/'))throw Error('output_overlap'); + noLinks(dirname(out));const parent=lstatSync(dirname(out));if(parent.uid!==process.getuid()||(parent.mode&0o077))throw Error('private_parent'); + const state=JSON.parse(privateRead(join(homedir(),'.config/coulomb-social/auth/storage-state.json')));assertSessionFresh(state); + mkdirSync(out,{mode:0o700});mkdirSync(join(out,'files'),{mode:0o700}); + const result={schema:'coulomb.bubble-attachment-modal-capture.v1',status:'capturing', + started_at:new Date().toISOString(),source_articles_sha256:sha(raw),target_article_ids:parents, + resources:{records:{},empty:{}},articles:{},attempts:[],files:{}, + resource_completeness_verified:false,source_mutations:false,production_imported:false}; + function checkpoint(){const f=join(out,'capture.json.partial'),fd=openSync(f,'w',0o600);try{writeFileSync(fd,JSON.stringify(result,null,2)+'\n');fsyncSync(fd);}finally{closeSync(fd);}renameSync(f,join(out,'capture.json'));const d=openSync(out,'r');try{fsyncSync(d);}finally{closeSync(d);}} + checkpoint(); + browser=await chromium.launch({headless:true});const context=await browser.newContext({storageState:state,acceptDownloads:true}); + const page=await context.newPage(),pending=new Set(),observedArticles={},observedResources={records:{},empty:{}}; + let responseFailures=0; + const observer=r=>{if(!isCorpusResponse(r.url(),BASE))return;const p=(async()=>{const b=await boundedCaptureBody(r);if(b.text&&r.ok()){ingestResourceBody(b.text,observedResources);ingestCapturedBody(b.text,{},observedArticles);}else responseFailures++;})();pending.add(p);p.finally(()=>pending.delete(p));}; + page.on('response',observer); + async function retain(url){ + if(result.files[url])return result.files[url]; + const response=await fetch(url,{redirect:'error',signal:AbortSignal.timeout(90000)}); + if(!response.ok||!response.body||Number(response.headers.get('content-length'))>MAX_FILE)throw Error('download_refused'); + const path='files/'+sha(Buffer.from(url)),fd=openSync(join(out,path),'wx',0o600),hash=createHash('sha256');let size=0; + try{for await(const chunk of response.body){size+=chunk.length;total+=chunk.length;if(size>MAX_FILE||total>MAX_TOTAL)throw Error('download_bound');hash.update(chunk);let offset=0;while(offsetdownloads.push(d); + const onPage=p=>p.on('download',onDownload);page.on('download',onDownload);context.on('page',onPage); + try{ + await buttons.nth(i).click(); + for(let n=0;n<60&&!downloads.length;n++)await page.waitForTimeout(100); + if(downloads.length!==1)throw Error('single_ui_download_required'); + const download=downloads[0],url=download.url();const matches=rows.filter(r=>r.url===url); + if(matches.length!==1)throw Error('ui_source_binding'); + const row=matches[0],file=await retain(url); + const received=await download.path(); + if(!received||lstatSync(received).size!==file.bytes||sha(readFileSync(received))!==file.sha256) + throw Error('independent_download_byte_mismatch'); + attempt.rows.push({...row,...file,suggested_filename:download.suggestedFilename(), + ui_download_url_verified:true,ui_and_independent_download_bytes_match:true}); + }finally{page.off('download',onDownload);context.off('page',onPage);for(const p of context.pages())if(p!==page)await p.close();} + } + if(new Set(attempt.rows.map(r=>r.relation_id)).size!==rows.length)throw Error('duplicate_or_missing_relation'); + for(const row of rows){const key='custom.relattachment:'+row.relation_id;result.resources.records[key]=observedResources.records[key];result.articles[row.article_id]=observedArticles[row.article_id];} + if(!observedArticles[id])throw Error('parent_source_missing');result.articles[id]=observedArticles[id]; + result.attempts.push(attempt);checkpoint(); + console.log(JSON.stringify({visited:result.attempts.length,modal_files:count,retained_files:Object.keys(result.files).length,retained_bytes:total})); + } + page.off('response',observer);await Promise.allSettled([...pending]); + await browser.close();browser=null; + if(sha(privateRead(source))!==result.source_articles_sha256)throw Error('source_changed'); + result.status='completed_bounded_attachment_modal_capture';result.completed_at=new Date().toISOString();checkpoint(); + console.log(JSON.stringify({status:result.status,visited:result.attempts.length,relations:Object.keys(result.resources.records).length,files:Object.keys(result.files).length,bytes:total,private_capture_sha256:sha(readFileSync(join(out,'capture.json'))),source_mutations:false,production_imported:false,resource_completeness_verified:false})); +}catch(error){if(browser)await browser.close();console.error('Attachment modal capture stopped: '+(error.message||'unknown').replace(/[^a-zA-Z0-9_]/g,'_').slice(0,80));process.exitCode=1;} diff --git a/scripts/tests/bubble-resource-utils.test.mjs b/scripts/tests/bubble-resource-utils.test.mjs index 5270e86..2d1a8e0 100644 --- a/scripts/tests/bubble-resource-utils.test.mjs +++ b/scripts/tests/bubble-resource-utils.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {ingestResourceBody, resourceParents} from '../bubble-resource-utils.mjs'; +import {ingestResourceBody, resourceParents, attachmentRows} from '../bubble-resource-utils.mjs'; const id='1700000000000x1', other='1700000000000x2'; const output=()=>({records:{},empty:{}}); @@ -45,3 +45,43 @@ test('NDJSON selects only content-resource types',()=>{ JSON.stringify({_type:'session',_id:other,_source:{cookie:'synthetic'}}),result); assert.deepEqual(Object.keys(result.records),['custom.relattachment:'+id]); }); + +function attachmentFixture() { + const relation={_type:'custom.relattachment',_id:'1700000000000x3', + to_custom_article:id,_chunk_custom_article:other}; + const resources={records:{'custom.relattachment:1700000000000x3':[relation]}}; + const articles={[other]:{context_custom_article:id,nomer_text:'Synthetic file', + logo_image:'//0123456789abcdef0123456789abcdef.cdn.bubble.io/synthetic.pdf', + 'Created Date':1700000000000,'Modified Date':1700000000001}}; + return {resources,articles}; +} + +test('attachment relation binds parent, original file and exact source dates',()=>{ + const {resources,articles}=attachmentFixture(); + const [row]=attachmentRows(id,resources,articles); + assert.equal(row.article_id,other); + assert.equal(row.url,'https:'+articles[other].logo_image); + assert.equal(row.created_at,1700000000000); + assert.equal(row.modified_at,1700000000001); + assert.deepEqual(attachmentRows('1700000000000x9',resources,articles),[]); +}); + +test('incomplete, cross-parent and drifting attachment relations are refused',()=>{ + for(const change of [f=>delete f.articles[other], + f=>{f.articles[other].context_custom_article='1700000000000x9';}, + f=>{f.resources.records['custom.relattachment:1700000000000x3'].push( + {...f.resources.records['custom.relattachment:1700000000000x3'][0],_version:2});}]) { + const f=attachmentFixture();change(f); + assert.throws(()=>attachmentRows(id,f.resources,f.articles),/attachment_/); + } +}); + +test('file retrieval requires the observed HTTPS Bubble CDN, without credentials',()=>{ + for(const url of ['http://0123456789abcdef0123456789abcdef.cdn.bubble.io/file', + 'https://example.invalid/file','https://localhost/file', + 'https://secret@0123456789abcdef0123456789abcdef.cdn.bubble.io/file', + 'https://0123456789abcdef0123456789abcdef.cdn.bubble.io:444/file']) { + const f=attachmentFixture();f.articles[other].logo_image=url; + assert.throws(()=>attachmentRows(id,f.resources,f.articles),/attachment_file_origin/); + } +}); diff --git a/workplans/CSOC-WP-0013-sharing-content-hardening.md b/workplans/CSOC-WP-0013-sharing-content-hardening.md index 4766c08..3e06193 100644 --- a/workplans/CSOC-WP-0013-sharing-content-hardening.md +++ b/workplans/CSOC-WP-0013-sharing-content-hardening.md @@ -222,6 +222,23 @@ Private evidence is resources-hover-20261031 alongside the original review; public receipt: docs/release/bubble-resource-hover-2026-10-31.json. The exact viewing-path or per-case disposition question remains pending. +2026-10-31 confirmed modal follow-through: the user identified Add attachment as +both the add and download modal. Opening it and using Open file recovered two +original files, one on the retained flagged page and one on the user-supplied +example outside the original245article capture. Each UI download exactly matches +an independent CDN fetch;2relations,2new attachment child records and original +creation/modification timestamps are retained privately. All5previously flagged +parent bodies/dates remain unchanged. The known parent draft now carries its +attachment and date provenance; its body and existing metadata remain exact. +The example stays separately retained pending source reconciliation. The4scrap +relations remain unresolved; their empty attachment modals do not prove that +scraps are absent. No source mutation, original-evidence overwrite, production +import or productive verdict occurred. Capture and private preparation evidence: +docs/release/bubble-attachment-recovery-2026-10-31.json. Private downloads/review: +~/.config/coulomb-social/auth/exports/attachment-bundle-20261031/attachments-index.html. +All670full-gate checks and7synthetic resource guards pass. This clears the unknown +attachment-modal navigation path; it does not waive the remaining source gaps. + ## T03 — Review productive sharing and personal portability ```task diff --git a/workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md b/workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md index 07d0ad5..9e79653 100644 --- a/workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md +++ b/workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md @@ -32,7 +32,7 @@ id: CSOC-WP-0014-T01 status: progress priority: high needs_human: true -intervention_note: "Fresh source session verified on 2026-10-31. Four source graph exception groups still need original content/date evidence or explicit disposition: ~/.config/coulomb-social/auth/exports/source-exception-review-20261031/index.html. Also awaiting the existing-resource viewing/download path for four scrap relations and one attachment flag: ~/.config/coulomb-social/auth/exports/resources-20261031/index.html. Bounded capture is terminal; no further login renewal is currently required." +intervention_note: "Fresh source session verified on 2026-10-31. Four source graph exception groups still need original content/date evidence or explicit disposition: ~/.config/coulomb-social/auth/exports/source-exception-review-20261031/index.html. The user-confirmed Add attachment modal yielded two verified files; private review is ~/.config/coulomb-social/auth/exports/attachment-bundle-20261031/attachments-index.html. Four separate scrap relations remain unresolved; empty attachment modals do not establish scrap absence. The additional example and two attachment child records need source reconciliation. Bounded capture is terminal; no further login renewal is currently required." state_hub_task_id: "0002882d-f156-5a5d-b524-d04793f4684d" ``` @@ -161,6 +161,24 @@ capture and Visual preparation are unchanged. Evidence: `docs/release/bubble-resource-capture-2026-10-31.json`. T01 stays progress and the existing source-resource gaps remain explicit until recovery or disposition. +2026-10-31 confirmed modal follow-through: the user identified Add attachment as +both the add and download modal. Opening it and using Open file recovered two +original files, one on the retained flagged page and one on the user-supplied +example outside the original245article capture. Each UI download exactly matches +an independent CDN fetch;2relations,2new attachment child records and original +creation/modification timestamps are retained privately. All5previously flagged +parent bodies/dates remain unchanged. The known parent draft now carries its +attachment and date provenance; its body and existing metadata remain exact. +The example stays separately retained pending source reconciliation. The4scrap +relations remain unresolved; their empty attachment modals do not prove that +scraps are absent. No source mutation, original-evidence overwrite, production +import or productive verdict occurred. Capture and private preparation evidence: +docs/release/bubble-attachment-recovery-2026-10-31.json. Private downloads/review: +~/.config/coulomb-social/auth/exports/attachment-bundle-20261031/attachments-index.html. +All670full-gate checks and7synthetic resource guards pass. This clears the unknown +attachment-modal navigation path; it does not waive the remaining source gaps. + + ## T02 — Review remaining journeys and record transfer decisions ```task