// Family devices: making one, the board, picking a profile (kids tap, others use a PIN), short sessions, and turning one off. const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto'); process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-board-')); process.env.COOKIE_SECRET = 'x'.repeat(40); const users = require('../users'), auth = require('../auth'), board = require('../board'), chores = require('../chores'), notes = require('../notes'), tasks = require('../tasks'), cal = require('../cal'); const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), kid = users.create({ name: 'Kai', role: 'user', tabs: ['chat', 'chores', 'tasks', 'notes', 'calendar'], handle: 'Yg' }), bob = users.create({ name: 'Bob', role: 'user', tabs: ['chat', 'chores'], handle: 'Yw' }); users.update(kid.id, { kid: { on: true } }); const mac = v => crypto.createHmac('sha256', auth.secret).update(v).digest('base64url'); const session = u => { const exp = Date.now() + 864e5; return `hub_session=${u.id}.${u.epoch}.${exp}.${mac(`s${u.id}.${u.epoch}.${exp}`)}`; }; const hub = http.createServer((req, res) => req.url.startsWith('/api/chores') ? users.runAs(ann, () => chores.handle(req, res)) : req.url.startsWith('/api/notes') ? users.runAs(ann, () => notes.handle(req, res)) : req.url.startsWith('/api/tasks') ? users.runAs(ann, () => tasks.handle(req, res)) : req.url.startsWith('/api/cal') ? users.runAs(ann, () => cal.handle(req, res)) : board.handle(req, res)).listen(0, async () => { const B = 'http://127.0.0.1:' + hub.address().port; let jar = {}; const call = async (p, o = {}, cookie) => { const r = await fetch(B + p, { ...o, headers: { ...(cookie ? { cookie } : {}), ...(o.headers || {}) } }); return { status: r.status, json: await r.json().catch(() => ({})), set: r.headers.getSetCookie ? r.headers.getSetCookie() : [] }; }; const post = (p, b, cookie) => call(p, { method: 'POST', body: JSON.stringify(b || {}) }, cookie); try { assert.equal((await call('/api/board')).status, 403, 'no board for a plain browser'); assert.equal((await post('/api/device/enable', {}, session(bob))).status, 403, 'only an admin makes a family device'); let r = await post('/api/device/enable', { name: 'Kitchen' }, session(ann)); assert.equal(r.status, 200); const dev = r.set.find(c => c.startsWith('hub_device=')).split(';')[0]; assert.ok(dev); assert.ok(r.set.some(c => c.startsWith('hub_session=;') ), 'the admin is signed out of the device'); r = await call('/api/board?day=2026-10-26', {}, dev); assert.equal(r.status, 200); assert.deepEqual(r.json.people.map(p => [p.name, p.kid]), [['Ann', false], ['Kai', true], ['Bob', false]]); users.update(bob.id, { board: false }); r = await call('/api/board?day=2026-10-26', {}, dev); assert.deepEqual(r.json.people.map(p => p.name), ['Ann', 'Kai'], 'only the people the admin picks are on the board'); assert.equal((await post('/api/board/switch', { uid: bob.id, pin: '2468' }, dev)).status, 404); users.update(bob.id, { board: true }); assert.equal((await call('/api/board', {}, 'hub_device=fake.sig')).status, 403, 'a forged device cookie is refused'); r = await post('/api/board/switch', { uid: kid.id }, dev); assert.equal(r.status, 200, JSON.stringify(r.json)); const sess = r.set.find(c => c.startsWith('hub_session=')); assert.ok(/Max-Age=1800/.test(sess), 'a short session on a family device'); assert.equal(auth.currentUser({ headers: { cookie: sess.split(';')[0] } }).id, kid.id); const ch = (await post('/api/chores', { title: 'Sweep', repeat: 'daily', assign: 'anyone', points: 4 })).json, today = new Date().toISOString().slice(0, 10), act = (uid, action, pin) => post('/api/board/chore', { uid, chore: ch.id, action, pin, date: today }, dev); let cb = (await call('/api/board?day=' + today, {}, dev)).json.chores.find(c => c.chore === ch.id); assert.equal(cb.claimable, true, 'the board shows the chore up for grabs'); assert.equal((await act(kid.id, 'claim')).status, 200, 'Kai takes it with one tap'); cb = (await call('/api/board?day=' + today, {}, dev)).json.chores.find(c => c.chore === ch.id); assert.equal(cb.claimed, true); assert.equal(cb.userName, 'Kai'); assert.equal((await act(bob.id, 'done')).status, 403, 'Bob has no PIN, so he cannot act as himself yet'); assert.equal((await act(kid.id, 'done')).status, 200); cb = (await call('/api/board?day=' + today, {}, dev)).json.chores.find(c => c.chore === ch.id); assert.equal(cb.done, true); assert.equal(cb.doneBy, 'Kai'); assert.equal((await act(kid.id, 'undo')).status, 200); assert.equal((await post('/api/board/chore', { uid: kid.id, chore: ch.id, action: 'done' }, '')).status, 403, 'only a family device can do this'); assert.equal((await act(kid.id, 'nope')).status, 400); const add = b => post('/api/board/add', { uid: kid.id, ...b }, dev), day0 = new Date().toISOString().slice(0, 10); assert.equal((await add({ kind: 'task', text: 'Water the plants', due: day0 })).status, 200, 'Kai adds a family task'); assert.ok((await call('/api/tasks?list=local:family', {}, 'hub_session=' + session(ann).slice(12))).status !== 500); assert.ok(JSON.stringify((await call('/api/board?day=' + day0, {}, dev)).json.tasks).includes('Water the plants'), 'and it shows on the board'); assert.equal((await add({ kind: 'event', text: 'Movie night', date: day0, time: '19:00', tz: 'UTC' })).status, 200); assert.ok(JSON.stringify((await call('/api/board?day=' + day0, {}, dev)).json.events).includes('Movie night'), 'a timed event lands on the family calendar'); assert.equal((await add({ kind: 'event', text: 'Grandma visits', date: day0 })).status, 200, 'or an all-day one'); const nt = (await post('/api/notes', { type: 'list', title: 'Packing', items: ['Hats'], share: 'family' })).json; assert.equal((await add({ kind: 'item', text: 'Sunscreen', note: nt.id })).status, 200); assert.equal((await call('/api/board?day=' + day0, {}, dev)).json.notes.find(n => n.id === nt.id).total, 2, 'the item is on the family list'); assert.equal((await add({ kind: 'item', text: 'x', note: 'nope' })).status, 404); const bd = (await call('/api/board?day=' + day0, {}, dev)).json; assert.equal(bd.tasks.find(t => t.title === 'Water the plants').by, 'Kai', 'the task says who added it'); assert.equal(bd.events.find(e => e.title === 'Movie night').by, 'Kai', 'and so does the event'); assert.equal((await call('/api/notes')).json.notes.find(n => n.id === nt.id).items.find(i => i.text === 'Sunscreen').addedBy, 'Kai', 'and the list item'); assert.equal((await add({ kind: 'shopping', text: 'Milk' })).status, 403, 'no Recipes tab: not allowed'); assert.equal((await add({ kind: 'task', text: '' })).status, 400); assert.equal((await add({ kind: 'bogus', text: 'x' })).status, 400); assert.equal((await post('/api/board/add', { uid: kid.id, kind: 'task', text: 'x' }, '')).status, 403, 'only on a family device'); r = await post('/api/board/switch', { uid: bob.id }, dev); assert.equal(r.status, 403, 'an adult with no PIN cannot just tap'); assert.equal((await post('/api/board/switch', { uid: kid.id }, '')).status, 403, 'and only a family device can switch'); assert.equal((await post('/api/device/pin', { pin: '12' }, session(bob))).status, 400, 'a PIN is 4 to 8 digits'); assert.equal((await post('/api/device/pin', { pin: '2468' }, session(bob))).status, 200); assert.equal((await call('/api/board?day=2026-10-26', {}, dev)).json.people.find(p => p.name === 'Bob').pinLen, 4, 'the board knows how many digits, to enter the PIN as soon as it is all typed'); assert.equal((await post('/api/board/switch', { uid: bob.id, pin: '1111' }, dev)).status, 401); assert.equal((await post('/api/board/switch', { uid: bob.id, pin: '2468' }, dev)).status, 200); for (let i = 0; i < 5; i++) await post('/api/board/switch', { uid: bob.id, pin: '0000' }, dev); assert.equal((await post('/api/board/switch', { uid: bob.id, pin: '2468' }, dev)).status, 429, 'five wrong tries lock it for a few minutes'); assert.equal((await call('/api/device', {}, session(ann) + '; ' + dev)).json.idleMin, 5, 'five idle minutes unless changed'); assert.equal((await post('/api/device/idle', { minutes: 2 }, session(bob))).status, 403); assert.equal((await post('/api/device/idle', { minutes: 7 }, session(ann))).status, 400, 'only the offered choices'); assert.equal((await post('/api/device/idle', { minutes: 2 }, session(ann))).status, 200); assert.equal((await call('/api/device', {}, session(ann) + '; ' + dev)).json.idleMin, 2, 'the new idle time sticks'); assert.equal((await call('/api/device', {}, session(ann) + '; ' + dev)).json.devices.length, 1); r = await post('/api/device/remove', {}, session(ann) + '; ' + dev); assert.equal(r.status, 200); assert.equal((await call('/api/board', {}, dev)).status, 403, 'switched off: the old cookie no longer works'); console.log('board ok'); } catch (e) { console.error(e); process.exitCode = 1; } hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50); });