Extensions: notes, recipes and music in the bridge, a permissions screen, and an Updated notice

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 10:27:19 -04:00
1 parent 808274b4fe
commit f80b718bd4
5 files changed
+38 -13

No files matched your search

+1 -1
View File
@@ -160,7 +160,7 @@ One **Store** tab (turn it on per person) with sections for **Books** (the free
- **Music** (MP3, M4A, AAC, FLAC, OGG, OPUS, WAV): an album can have many tracks; they play in the hub's player and can be downloaded.
- **Apps**: any files to download (an APK, an installer...), with an optional link and version. The hub does not run them.
- **Sites**: any website as a card. It opens in a new tab, or inside the hub if you tick *Open inside the hub* (some sites refuse to be shown that way).
- **Extensions**: one self-contained `.html` file that adds a feature to the hub. A person installs it from My Store and it opens inside the Store. It runs in a sandbox with no access to the hub's sign-in or data (and `connect-src https:` only), and talks to the hub through a small `hub` object: `hub.storage.get/set/all/remove` (its own settings for that person, up to 256 KB), `hub.user()` (the person's name), `hub.toast(text)` and `hub.open(url)`. It can also ask for hub data, which the person approves the first time (and which is forgotten when the extension is removed): `hub.tasks.lists() / list(listId) / add({ title, notes, due, list }) / complete(listId, taskId)`, `hub.contacts.list() / add({ name, email, phone, org })` and `hub.calendar.events(from, to) / add({ title, start, end, allDay })` (dates as `YYYY-MM-DD`, times as `YYYY-MM-DDTHH:MM`). A refused request rejects with an error. Ready-made examples are in `docs/extensions/`: **notes.html** (notes and checklists, can send items to Tasks), **tracker.html** (habit and Bible-reading streaks), **kitchen.html** (unit converter and recipe scaler that can add to your Shopping list), **weather.html** (forecast plus a verse of the day) and **notepad.html** (the smallest one).
- **Extensions**: one self-contained `.html` file that adds a feature to the hub. A person installs it from My Store and it opens inside the Store. It runs in a sandbox with no access to the hub's sign-in or data (and `connect-src https:` only), and talks to the hub through a small `hub` object: `hub.storage.get/set/all/remove` (its own settings for that person, up to 256 KB), `hub.user()` (the person's name), `hub.toast(text)` and `hub.open(url)`. It can also ask for hub data, which the person approves the first time (and which is forgotten when the extension is removed): `hub.tasks.lists() / list(listId) / add({ title, notes, due, list }) / complete(listId, taskId)`, `hub.contacts.list() / add({ name, email, phone, org })` and `hub.calendar.events(from, to) / add({ title, start, end, allDay })` (dates as `YYYY-MM-DD`, times as `YYYY-MM-DDTHH:MM`), `hub.notes.list() / add({ title, body })` (new notes are private), `hub.recipes.search(text) / get(slug)` and `hub.music.nowPlaying()` (what is playing, or `null`). A refused request rejects with an error. Each permission can be switched off again from the extension's **Permissions** button, and when an admin changes an extension (its version or file) the people who have it see "Updated" the next time they open it. Ready-made examples are in `docs/extensions/`: **notes.html** (notes and checklists, can send items to Tasks), **tracker.html** (habit and Bible-reading streaks), **kitchen.html** (unit converter and recipe scaler that can add to your Shopping list), **weather.html** (forecast plus a verse of the day) and **notepad.html** (the smallest one).
Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions up to 2 MB). Only an admin can add, change or delete items.
+11 -5
View File
@@ -13,7 +13,7 @@ const ROOT = () => path.join(DATA_DIR, 'mystore'), ITEMS = () => path.join(ROOT(
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', 'link'];
const PERMS = ['tasks.read', 'tasks.write', 'contacts.read', 'contacts.write', 'calendar.read', 'calendar.write']; // what an extension may ask the person for
const PERMS = ['tasks.read', 'tasks.write', 'contacts.read', 'contacts.write', 'calendar.read', 'calendar.write', 'notes.read', 'notes.write', 'recipes.read', 'music.read']; // 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' };
@@ -37,7 +37,7 @@ const view = it => ({ id: it.id, kind: it.kind, title: it.title, creator: it.cre
// ---- 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 : {}, grants: j.grants && typeof j.grants === 'object' ? j.grants : {} }; } catch { return { installed: [], data: {}, grants: {} }; } };
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 : {}, seen: j.seen && typeof j.seen === 'object' ? j.seen : {} }; } catch { return { installed: [], data: {}, grants: {}, seen: {} }; } };
const saveState = s => atomicWrite(stateFile(), JSON.stringify(s), { mode: 0o600 });
/** Stream a stored file, with Range support so audio can be skipped through. */
@@ -84,14 +84,17 @@ function call(method,args){return new Promise(function(ok,no){var id=++n;w[id]=[
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>`;
calendar:{events:function(from,to){return call('calendar.events',[{from:from,to:to}])},add:function(o){return call('calendar.add',[o])}},
notes:{list:function(){return call('notes.list')},add:function(o){return call('notes.add',[o])}},
recipes:{search:function(q){return call('recipes.search',[{q:q}])},get:function(slug){return call('recipes.get',[{slug:slug}])}},
music:{nowPlaying:function(){return call('music.nowPlaying')}}}})()</script>`;
const withBridge = html => /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, m => m + BRIDGE) : BRIDGE + html;
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/store/my'.length) || '/', me = users.current();
try {
let m;
if (p === '/items' && req.method === 'GET') return json(res, 200, { items: loadItems().sort((a, b) => b.added - a.added).map(view), installed: stateOf().installed, admin: !!me && me.role === 'admin' });
if (p === '/items' && req.method === 'GET') return json(res, 200, { items: loadItems().sort((a, b) => b.added - a.added).map(view), installed: stateOf().installed, seen: stateOf().seen, admin: !!me && me.role === 'admin' });
if (p === '/items' && req.method === 'POST') { // add or change an item's details (admin)
needAdmin(me); const b = JSON.parse(await readBody(req, 1e5) || '{}'), l = loadItems(), old = b.id ? l.find(x => x.id === b.id) : null;
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');
@@ -148,7 +151,10 @@ 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]; delete s.grants[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); s.seen[it.id] = it.updated || 0; } if (m[2] === 'uninstall') { delete s.data[it.id]; delete s.grants[it.id]; delete s.seen[it.id]; } saveState(s); return json(res, 200, { installed: s.installed });
}
if ((m = /^\/ext\/([0-9a-f]{12})\/seen$/.exec(p)) && req.method === 'POST') { // the person has looked at this version of the extension (so it stops saying it was updated)
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found'); const s = stateOf(); if (s.installed.includes(it.id)) { s.seen[it.id] = it.updated || 0; saveState(s); } return json(res, 200, { seen: s.seen });
}
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();
+20 -6
View File
@@ -8,7 +8,8 @@ const stKind = k => ST_KINDS.find(x => x[0] === k) || ST_KINDS[2];
const stSize = b => fmtSize(+b || 0);
const stCover = it => it.cover ? `/api/store/my/items/${it.id}/cover?v=${it.updated}` : '';
const stHue = s => { let h = 0; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) % 360; return h; };
let stItems = [], stInstalled = [], stAdmin = false, stFilter = 'all';
let stItems = [], stInstalled = [], stSeen = {}, stAdmin = false, stFilter = 'all';
const stUpdated = it => it.kind === 'extension' && stInstalled.includes(it.id) && stSeen[it.id] !== undefined && (it.updated || 0) > stSeen[it.id]; // a new version of an extension you have (one installed before this was tracked is not flagged)
/** The sections this person may open (each needs its own tab's permission, except My Store). */
const stSections = () => [['books', 'Books', allowed('read')], ['audiobooks', 'Audiobooks', allowed('audio')], ['radio', 'Radio', allowed('radio')], ['mine', 'My Store', true]].filter(s => s[2]);
@@ -28,7 +29,7 @@ async function viewStore(args = []) {
}
// ---------------------------------------------------------------- My Store
async function stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stAdmin = r.admin; return r; }
async function stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stSeen = r.seen || {}; stAdmin = r.admin; return r; }
async function stMine(el) {
try { await stLoad(); } catch (e) { el.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
const draw = () => {
@@ -154,15 +155,17 @@ async function stRunExt(el, id) {
const it = stItems.find(x => x.id === id && x.kind === 'extension');
if (!it) { el.innerHTML = '<p class="empty">That extension is not in the store any more.</p>'; return; }
if (!stInstalled.includes(id)) { el.innerHTML = `<p class="empty">Install “${esc(it.title)}” from My Store first.</p>`; return; }
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><a class="btn ghost" href="#/store/mine">${icon('back')}My Store</a><b style="flex:1">${esc(it.title)}</b></div>
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><a class="btn ghost" href="#/store/mine">${icon('back')}My Store</a><b style="flex:1">${esc(it.title)}${it.version ? ` <small style="color:var(--muted)">v${esc(it.version)}</small>` : ''}</b><button class="btn ghost" id="stperm">Permissions</button></div>
${stUpdated(it) ? `<div class="card" id="stupd" style="max-width:none;margin:0 0 8px"><b>Updated</b> ${esc(new Date(it.updated).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }))}${it.version ? ' to version ' + esc(it.version) : ''}.${it.description ? ' ' + esc(it.description.split('\n')[0].slice(0, 160)) : ''}<div class="bar" style="margin:8px 0 0"><button class="btn ghost" id="stupdx">Got it</button></div></div>` : ''}
<iframe id="stframe" title="${esc(it.title)}" sandbox="allow-scripts allow-forms allow-popups allow-modals" referrerpolicy="no-referrer" style="width:100%;height:calc(100dvh - 190px);min-height:360px;border:1px solid var(--line);border-radius:12px;background:#fff"></iframe>`;
const frame = el.querySelector('#stframe'); let data = null, timer;
if (stUpdated(it)) { stApi(`/ext/${id}/seen`, stOpt('POST', {})).then(r => { stSeen = r.seen || stSeen; }).catch(() => {}); const x = el.querySelector('#stupdx'); if (x) x.onclick = () => el.querySelector('#stupd').remove(); }
// (the page is fetched by the hub and shown from here, so the frame needs no sign-in of its own; its sandbox keeps it from touching anything of the hub's)
fetch(`/api/store/my/ext/${id}/page`).then(r => r.ok ? r.text() : Promise.reject(new Error('Could not load it'))).then(h => { frame.srcdoc = h; }).catch(e => { el.querySelector('#stframe').outerHTML = `<p class="err">${esc(e.message)}</p>`; });
const load = async () => data || (data = await stApi(`/ext/${id}/data`).catch(() => ({})));
const save = () => { clearTimeout(timer); timer = setTimeout(() => stApi(`/ext/${id}/data`, stOpt('POST', data)).catch(e => toast(e.message)), 400); };
// What an extension may ask the hub for. Each kind of access is asked of the person the first time and remembered until the extension is removed.
const WHY = { 'tasks.read': 'see your tasks', 'tasks.write': 'add tasks and mark them done', 'contacts.read': 'see your contacts', 'contacts.write': 'add contacts', 'calendar.read': 'see your calendar', 'calendar.write': 'add events to your calendar' };
const WHY = { 'tasks.read': 'see your tasks', 'tasks.write': 'add tasks and mark them done', 'contacts.read': 'see your contacts', 'contacts.write': 'add contacts', 'calendar.read': 'see your calendar', 'calendar.write': 'add events to your calendar', 'notes.read': 'see your notes and lists', 'notes.write': 'add notes', 'recipes.read': 'search and read your recipes', 'music.read': 'see what music is playing' };
let grants = null; const refused = new Set();
const allowedTo = async perm => {
if (!grants) grants = (await stApi(`/ext/${id}/grants`).catch(() => ({ grants: [] }))).grants;
@@ -172,10 +175,16 @@ async function stRunExt(el, id) {
if (!yes) { refused.add(perm); return false; }
grants = [...grants, perm]; stApi(`/ext/${id}/grants`, stOpt('POST', { grants })).catch(() => {}); return true;
};
// The permissions screen: everything this extension may reach, each one switched off or on (turning one off just means it is asked again next time it needs it)
el.querySelector('#stperm').onclick = async () => {
if (!grants) grants = (await stApi(`/ext/${id}/grants`).catch(() => ({ grants: [] }))).grants;
const sh = openSheet('Permissions', it.title, `<p class="sub" style="margin-top:0">What “${esc(it.title)}” may reach in the hub. Switch one off and it has to ask again.</p><div class="list">${Object.entries(WHY).map(([k, w]) => `<label class="row" style="cursor:pointer"><span class="grow"><b>${esc(w[0].toUpperCase() + w.slice(1))}</b><small>${esc(k)}</small></span><input type="checkbox" data-p="${esc(k)}"${grants.includes(k) ? ' checked' : ''}></label>`).join('')}</div>`);
sh.querySelectorAll('[data-p]').forEach(c => c.onchange = async () => { const next = c.checked ? [...new Set([...grants, c.dataset.p])] : grants.filter(x => x !== c.dataset.p); try { await stApi(`/ext/${id}/grants`, stOpt('POST', { grants: next })); grants = next; refused.delete(c.dataset.p); } catch (e) { c.checked = !c.checked; toast(e.message); } });
};
const jf = (p, o) => jfetch(p, o), pj = (m, b) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) });
const txt = (v, n) => String(v == null ? '' : v).slice(0, n), ymd = v => /^\d{4}-\d{2}-\d{2}$/.test(String(v)) ? String(v) : '';
const hubCall = async (method, a = {}) => {
const need = { 'tasks.lists': 'tasks.read', 'tasks.list': 'tasks.read', 'tasks.add': 'tasks.write', 'tasks.complete': 'tasks.write', 'contacts.list': 'contacts.read', 'contacts.add': 'contacts.write', 'calendar.events': 'calendar.read', 'calendar.add': 'calendar.write' }[method];
const need = { 'tasks.lists': 'tasks.read', 'tasks.list': 'tasks.read', 'tasks.add': 'tasks.write', 'tasks.complete': 'tasks.write', 'contacts.list': 'contacts.read', 'contacts.add': 'contacts.write', 'calendar.events': 'calendar.read', 'calendar.add': 'calendar.write', 'notes.list': 'notes.read', 'notes.add': 'notes.write', 'recipes.search': 'recipes.read', 'recipes.get': 'recipes.read', 'music.nowPlaying': 'music.read' }[method];
if (!need) throw new Error('Unknown request'); if (!a || typeof a !== 'object') a = {};
if (!(await allowedTo(need))) throw new Error('The person did not allow that');
const localList = async name => { const l = (await jf('/api/tasks/lists')).lists.filter(x => x.local), n = txt(name, 60).toLowerCase(); return (n && l.find(x => x.name.toLowerCase() === n)) || l.find(x => x.id === 'local:inbox') || l[0]; };
@@ -185,6 +194,11 @@ async function stRunExt(el, id) {
if (method === 'tasks.complete') { const list = txt(a.list, 80), t = (await jf('/api/tasks?list=' + encodeURIComponent(list))).find(x => x.id === a.id); if (!t) throw new Error('Task not found'); await jf('/api/tasks', pj('POST', { list, id: t.id, fields: { title: t.title, notes: t.notes || '', due: t.due || '', done: true } })); return { ok: true }; }
if (method === 'contacts.list') return (await ctApi('/')).map(c => ({ id: c.id, name: c.name, org: c.org, emails: c.emails.map(e => e.value), phones: c.phones.map(p => p.value), birthday: c.birthday }));
if (method === 'contacts.add') { const c = await ctApi('/', pj('POST', { name: txt(a.name, 120), org: txt(a.org, 120), emails: a.email ? [{ label: '', value: txt(a.email, 120) }] : [], phones: a.phone ? [{ label: '', value: txt(a.phone, 60) }] : [], birthday: txt(a.birthday, 12), notes: txt(a.notes, 2000) })); ctList = null; return { id: c.id }; }
if (method === 'notes.list') return (await jf('/api/notes/')).notes.slice(0, 200).map(n => ({ id: n.id, type: n.type, title: n.title, body: n.type === 'note' ? txt(n.body, 5000) : '', items: n.type === 'list' ? (n.items || []).map(i => ({ text: i.text, done: !!i.done })) : [] }));
if (method === 'notes.add') { if (!txt(a.title, 120) && !txt(a.body, 10)) throw new Error('Give the note a title or some text'); await jf('/api/notes/', pj('POST', { type: 'note', title: txt(a.title, 120), body: txt(a.body, 20000), share: 'private' })); return { ok: true }; }
if (method === 'recipes.search') return (await jf('/api/recipes/?page=1&q=' + encodeURIComponent(txt(a.q, 100)))).items.slice(0, 24).map(r => ({ slug: r.slug, name: r.name, minutes: r.minutes || 0 }));
if (method === 'recipes.get') { const r = await jf('/api/recipes/r/' + encodeURIComponent(txt(a.slug, 200))); return { name: r.name, description: txt(r.description, 1000), servings: r.servings || '', ingredients: r.ingredients.map(i => i.text || i.title), steps: r.steps.map(x => x.text) }; }
if (method === 'music.nowPlaying') { const t = typeof cur === 'function' && cur(); return t ? { title: t.title || '', artist: t.artist || t.sub || '', album: t.album || '', playing: !pl.a.paused } : null; }
if (method === 'calendar.events') { const f = ymd(a.from), t = ymd(a.to); if (!f || !t) throw new Error('Give from and to as YYYY-MM-DD'); const q = new URLSearchParams({ from: new Date(f + 'T00:00:00').getTime(), to: new Date(t + 'T23:59:59').getTime(), fd: f, td: t });
return (await jf('/api/cal/events?' + q)).events.slice(0, 500).map(e => ({ title: e.title, start: e.start, end: e.end, allDay: !!e.allDay, calendar: e.cal })); }
if (method === 'calendar.add') { const cals = await jf('/api/cal/list'), c = cals.find(x => x.local && !x.shared); if (!c) throw new Error('No calendar to add to'); const allDay = !!a.allDay, re = allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
@@ -202,7 +216,7 @@ async function stRunExt(el, id) {
else if (method === 'storage.remove') { delete (await load())[String(args[0])]; save(); result = true; }
else if (method === 'toast') { toast(String(args[0]).slice(0, 200)); result = true; }
else if (method === 'user') result = { name: ME ? ME.name : '' };
else if (method.startsWith('tasks.') || method.startsWith('contacts.') || method.startsWith('calendar.')) result = await hubCall(method, args[0]);
else if (/^(tasks|contacts|calendar|notes|recipes|music)\./.test(method)) result = await hubCall(method, args[0]);
else if (method === 'open') { if (/^https?:\/\//i.test(String(args[0]))) window.open(String(args[0]), '_blank', 'noopener,noreferrer'); result = true; }
else throw new Error('Unknown request');
} catch (x) { error = x.message; }
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v112';
const CACHE = 'hub-v113';
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
+5
View File
@@ -47,6 +47,11 @@ const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] ==
assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/grants`, { headers: bob })).json(), { grants: [] }); assert.equal((await post(`/ext/${ext.id}/install`, {}, bob)).status, 200);
assert.deepEqual((await (await post(`/ext/${ext.id}/grants`, { grants: ['tasks.read', 'tasks.read', 'root.everything'] }, bob)).json()).grants, ['tasks.read'], 'only known permissions are kept');
assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/grants`)).json(), { grants: [] }, 'they are per person'); await post(`/ext/${ext.id}/uninstall`, {}, bob); assert.deepEqual((await (await fetch(`${B}/ext/${ext.id}/grants`, { headers: bob })).json()).grants, [], 'removing the extension takes them back');
// the newer permissions, and "updated since you last looked"
await post(`/ext/${ext.id}/install`, {}, bob); assert.deepEqual((await (await post(`/ext/${ext.id}/grants`, { grants: ['notes.read', 'notes.write', 'recipes.read', 'music.read', 'tasks.read', 'notes.delete'] }, bob)).json()).grants.sort(), ['music.read', 'notes.read', 'notes.write', 'recipes.read', 'tasks.read'], 'notes, recipes and music can be granted; made-up ones cannot');
let li = await (await fetch(`${B}/items`, { headers: bob })).json(); assert.equal(li.seen[ext.id], li.items.find(x => x.id === ext.id).updated, 'installing marks this version as seen');
await new Promise(r => setTimeout(r, 15)); await post('/items', { id: ext.id, title: ext.title, version: '2.0' }); li = await (await fetch(`${B}/items`, { headers: bob })).json(); assert.ok(li.items.find(x => x.id === ext.id).updated > li.seen[ext.id], 'a new version is newer than the one you saw');
li = await (await post(`/ext/${ext.id}/seen`, {}, bob)).json(); assert.equal(li.seen[ext.id], (await (await fetch(`${B}/items`, { headers: bob })).json()).items.find(x => x.id === ext.id).updated, 'and looking at it clears that'); await post(`/ext/${ext.id}/uninstall`, {}, bob);
// a site (a link, optionally shown inside the hub)
assert.equal((await post('/items', { kind: 'link', title: 'Site' })).status, 400, 'a site needs an address'); let site = await (await post('/items', { kind: 'link', title: 'Docs', url: 'https://example.com/', embed: true, icon: 'D' })).json(); assert.equal(site.embed, true); assert.equal(site.icon, 'D');
assert.equal((await put(`/items/${site.id}/files?name=a.html`, 'x')).status, 400, 'a site has no files');