Recover original attachments through the confirmed Bubble modal
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 7m29s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a192e9-c28b-7533-bf25-e2341364c22a
This commit is contained in:
tegwick 2026-10-31 20:37:49 +01:00
parent 43b38429f4
commit 37e3bfc2c7
7 changed files with 251 additions and 2 deletions

View file

@ -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"
}

View file

@ -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.

View file

@ -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') &&

View file

@ -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(offset<chunk.length)offset+=writeSync(fd,chunk,offset,chunk.length-offset);}if(!size)throw Error('empty_download');fsyncSync(fd);}
catch(error){closeSync(fd);unlinkSync(join(out,path));throw error;}
closeSync(fd);const d=openSync(join(out,'files'),'r');try{fsyncSync(d);}finally{closeSync(d);}
const file={path,bytes:size,sha256:hash.digest('hex'),content_type:response.headers.get('content-type')||''};result.files[url]=file;return file;
}
for(const id of parents){
await page.goto(BASE+'/pg_chunk/'+id,{waitUntil:'domcontentloaded',timeout:30000});await page.waitForTimeout(2500);
if(page.url().split(/[?#]/)[0]!==BASE+'/pg_chunk/'+id)throw Error('source_route');
const opener=page.locator('[title="Add attachment"]:visible');if(await opener.count()!==1)throw Error('unique_modal_opener');
await opener.click();await page.waitForTimeout(2500);await Promise.allSettled([...pending]);
const modal=page.locator('.bubble-element.Popup:visible');if(await modal.count()!==1)throw Error('unique_modal');
const buttons=modal.locator('[title="Open file"]:visible'),count=await buttons.count();
const rows=attachmentRows(id,observedResources,observedArticles);if(count!==rows.length)throw Error('visible_row_binding');
const attempt={article_id:id,modal_opened:true,visible_file_actions:count,rows:[],response_failures:responseFailures};
for(let i=0;i<count;i++){
const downloads=[];const onDownload=d=>downloads.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;}

View file

@ -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/);
}
});

View file

@ -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

View file

@ -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