Extensions: hub bridge for tasks, contacts and calendar (asked of the person once), Sites in My Store, and four example extensions

- hub.tasks / hub.contacts / hub.calendar in the extension sandbox; each kind of access is approved by the person and remembered per extension
- My Store: Sites kind (any web link, optionally shown inside the hub)
- Examples: notes and checklists, habit tracker, kitchen converter and scaler, weather and verse of the day

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-26 09:25:39 -04:00
1 parent f292294ad7
commit efd17f4263
8 files changed
+204 -18

No files matched your search

+18 -7
View File
@@ -12,7 +12,8 @@ const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const ROOT = () => path.join(DATA_DIR, 'mystore'), ITEMS = () => path.join(ROOT(), 'items.json'), FILES = () => path.join(ROOT(), 'files');
const SEAFILE = () => (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
const MAX_FILE = 1024 * 1024 * 1024, MAX_COVER = 3 * 1024 * 1024, MAX_EXT = 2 * 1024 * 1024, MAX_STATE = 256 * 1024, MAX_FILES_PER_ITEM = 200;
const KINDS = ['book', 'music', 'app', 'extension'];
const KINDS = ['book', 'music', 'app', 'extension', 'link'];
const PERMS = ['tasks.read', 'tasks.write', 'contacts.read', 'contacts.write', 'calendar.read', 'calendar.write']; // what an extension may ask the person for
const BOOK_EXT = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'docx'];
const TYPES = { epub: 'application/epub+zip', pdf: 'application/pdf', mobi: 'application/x-mobipocket-ebook', azw3: 'application/vnd.amazon.mobi8-ebook', azw: 'application/vnd.amazon.ebook', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
mp3: 'audio/mpeg', m4a: 'audio/mp4', aac: 'audio/aac', flac: 'audio/flac', ogg: 'audio/ogg', opus: 'audio/ogg', wav: 'audio/wav', html: 'text/html; charset=utf-8', apk: 'application/vnd.android.package-archive', zip: 'application/zip', dmg: 'application/x-apple-diskimage', exe: 'application/vnd.microsoft.portable-executable', pkg: 'application/octet-stream', txt: 'text/plain; charset=utf-8' };
@@ -32,11 +33,11 @@ const saveItems = l => { fs.mkdirSync(FILES(), { recursive: true, mode: 0o700 })
const fileOn = (item, fid) => path.join(FILES(), item.id + '-' + fid);
const coverOn = item => path.join(FILES(), item.id + '-cover');
const view = it => ({ id: it.id, kind: it.kind, title: it.title, creator: it.creator, description: it.description, version: it.version, url: it.url, cover: !!it.cover, added: it.added, updated: it.updated,
files: it.files.map(f => ({ id: f.id, name: f.name, size: f.size, ext: f.ext })), ...(it.kind === 'extension' ? { icon: it.icon || '' } : {}) });
files: it.files.map(f => ({ id: f.id, name: f.name, size: f.size, ext: f.ext })), ...(it.kind === 'extension' || it.kind === 'link' ? { icon: it.icon || '' } : {}), ...(it.kind === 'link' ? { embed: !!it.embed } : {}) });
// ---- the per-person bits: which extensions are installed, and each extension's saved settings
const stateFile = () => path.join(users.dir(), 'mystore.json');
const stateOf = () => { try { const j = JSON.parse(fs.readFileSync(stateFile(), 'utf8')); return { installed: Array.isArray(j.installed) ? j.installed : [], data: j.data && typeof j.data === 'object' ? j.data : {} }; } catch { return { installed: [], data: {} }; } };
const stateOf = () => { try { const j = JSON.parse(fs.readFileSync(stateFile(), 'utf8')); return { installed: Array.isArray(j.installed) ? j.installed : [], data: j.data && typeof j.data === 'object' ? j.data : {}, grants: j.grants && typeof j.grants === 'object' ? j.grants : {} }; } catch { return { installed: [], data: {}, grants: {} }; } };
const saveState = s => atomicWrite(stateFile(), JSON.stringify(s), { mode: 0o600 });
/** Stream a stored file, with Range support so audio can be skipped through. */
@@ -80,7 +81,10 @@ const EXT_CSP = "sandbox allow-scripts allow-forms allow-popups allow-modals; de
const EXT_META = '<meta http-equiv="Content-Security-Policy" content="' + EXT_CSP.replace(/^sandbox [^;]*; /, '').replace(/; frame-ancestors 'self'$/, '') + '">'; // (the same rules, for when the page is shown from the hub's own copy of it)
const BRIDGE = EXT_META + `<script>(function(){var n=0,w={};window.addEventListener('message',function(e){var m=e.data;if(m&&m.hub&&w[m.id]){var f=w[m.id];delete w[m.id];m.error?f[1](new Error(m.error)):f[0](m.result)}});
function call(method,args){return new Promise(function(ok,no){var id=++n;w[id]=[ok,no];parent.postMessage({hub:1,id:id,method:method,args:args||[]},'*')})}
window.hub={storage:{get:function(k){return call('storage.get',[k])},set:function(k,v){return call('storage.set',[k,v])},all:function(){return call('storage.all')},remove:function(k){return call('storage.remove',[k])}},toast:function(t){return call('toast',[String(t)])},user:function(){return call('user')},open:function(u){return call('open',[String(u)])}}})()</script>`;
window.hub={storage:{get:function(k){return call('storage.get',[k])},set:function(k,v){return call('storage.set',[k,v])},all:function(){return call('storage.all')},remove:function(k){return call('storage.remove',[k])}},toast:function(t){return call('toast',[String(t)])},user:function(){return call('user')},open:function(u){return call('open',[String(u)])},
tasks:{lists:function(){return call('tasks.lists')},list:function(l){return call('tasks.list',[{list:l}])},add:function(o){return call('tasks.add',[o])},complete:function(l,id){return call('tasks.complete',[{list:l,id:id}])}},
contacts:{list:function(){return call('contacts.list')},add:function(o){return call('contacts.add',[o])}},
calendar:{events:function(from,to){return call('calendar.events',[{from:from,to:to}])},add:function(o){return call('calendar.add',[o])}}}})()</script>`;
const withBridge = html => /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, m => m + BRIDGE) : BRIDGE + html;
async function handle(req, res) {
@@ -93,9 +97,11 @@ async function handle(req, res) {
if (b.id && !old) throw fail(404, 'Item not found'); if (!old && !KINDS.includes(b.kind)) throw fail(400, 'Choose what kind of thing this is');
const title = str(b.title, 120); if (!title) throw fail(400, 'Give it a title');
const url = str(b.url, 300); if (url && !/^https?:\/\/\S+$/i.test(url)) throw fail(400, 'The link must start with http');
if ((old ? old.kind : b.kind) === 'link' && !url) throw fail(400, 'Paste the address of the site');
const it = old || { id: newId(), kind: b.kind, files: [], cover: false, added: Date.now() };
Object.assign(it, { title, creator: str(b.creator, 120), description: str(b.description, 2000), version: str(b.version, 30), url, updated: Date.now() });
if (it.kind === 'extension') it.icon = str(b.icon, 4);
if (it.kind === 'extension' || it.kind === 'link') it.icon = str(b.icon, 4);
if (it.kind === 'link') it.embed = !!b.embed;
if (!old) { if (l.length >= 2000) throw fail(400, 'That is plenty of items'); l.push(it); } saveItems(l); return json(res, 200, view(it));
}
if ((m = /^\/items\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') {
@@ -105,7 +111,7 @@ async function handle(req, res) {
if ((m = /^\/items\/([0-9a-f]{12})\/files$/.exec(p)) && req.method === 'POST') { // upload one file (the body is the file; its name is in ?name=) or copy one from Seafile (JSON { repo, path })
needAdmin(me); const l = loadItems(), it = l.find(x => x.id === m[1]); if (!it) throw fail(404, 'Item not found');
if (it.files.length >= MAX_FILES_PER_ITEM) throw fail(400, 'That is plenty of files for one item');
if (it.kind !== 'music' && it.kind !== 'app' && it.files.length >= 1) throw fail(400, 'This kind of item holds one file. Remove the old one first.');
if (it.kind === 'link') throw fail(400, 'A site has no files'); if (it.kind !== 'music' && it.kind !== 'app' && it.files.length >= 1) throw fail(400, 'This kind of item holds one file. Remove the old one first.');
const fid = newId(); let name, size;
if (/json/.test(req.headers['content-type'] || '')) { const b = JSON.parse(await readBody(req, 1e4) || '{}'), src = await fromSeafile(String(b.repo || ''), String(b.path || '')); name = src.name; const tmp = fileOn(it, fid) + '.part'; fs.mkdirSync(FILES(), { recursive: true, mode: 0o700 }); let n = 0; src.stream.on('data', c => { n += c.length; if (n > MAX_FILE) src.stream.destroy(fail(413, 'That file is too large')); }); await pipeline(src.stream, fs.createWriteStream(tmp, { mode: 0o600 })).catch(e => { fs.rm(tmp, { force: true }, () => {}); throw e.code ? e : fail(502, 'The copy did not finish'); }); fs.renameSync(tmp, fileOn(it, fid)); size = n; }
else { name = safeName(u.searchParams.get('name')); size = await receive(req, fileOn(it, fid), it.kind === 'extension' ? MAX_EXT : MAX_FILE); }
@@ -142,7 +148,12 @@ async function handle(req, res) {
}
if ((m = /^\/ext\/([0-9a-f]{12})\/(install|uninstall)$/.exec(p)) && req.method === 'POST') {
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found');
const s = stateOf(); s.installed = s.installed.filter(x => x !== it.id); if (m[2] === 'install') s.installed.push(it.id); if (m[2] === 'uninstall') delete s.data[it.id]; saveState(s); return json(res, 200, { installed: s.installed });
const s = stateOf(); s.installed = s.installed.filter(x => x !== it.id); if (m[2] === 'install') s.installed.push(it.id); if (m[2] === 'uninstall') { delete s.data[it.id]; delete s.grants[it.id]; } saveState(s); return json(res, 200, { installed: s.installed });
}
if ((m = /^\/ext\/([0-9a-f]{12})\/grants$/.exec(p))) { // what the person has allowed this extension to reach in the hub
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found'); const s = stateOf();
if (req.method === 'GET') return json(res, 200, { grants: s.grants[it.id] || [] });
if (req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'); s.grants[it.id] = [...new Set((Array.isArray(b.grants) ? b.grants : []).filter(x => PERMS.includes(x)))]; saveState(s); return json(res, 200, { grants: s.grants[it.id] }); }
}
if ((m = /^\/ext\/([0-9a-f]{12})\/data$/.exec(p))) { // the extension's own saved settings for this person
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found'); const s = stateOf();