Recover original attachments through the confirmed Bubble modal
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 7m29s
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:
parent
43b38429f4
commit
37e3bfc2c7
7 changed files with 251 additions and 2 deletions
41
docs/release/bubble-attachment-recovery-2026-10-31.json
Normal file
41
docs/release/bubble-attachment-recovery-2026-10-31.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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') &&
|
||||
|
|
|
|||
91
scripts/capture-bubble-attachments.mjs
Normal file
91
scripts/capture-bubble-attachments.mjs
Normal 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;}
|
||||
|
|
@ -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/);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue