Parent controls for children, name-tile sign-in, restricted Learn search, simple mode
build-container / build (push) Successful in 3m52s
build-container / build (push) Successful in 3m52s
- Sign-in page shows name tiles on remembered devices; tap a name, then PIN or passkey - Music and Movies are separate on the server; Plex can be locked to a child's managed profile - Activity log, weekly summary and alerts to parents; per-area and weekend limits - Requests from children (Ask button) answered in Settings; bonus screen time from rewards - Restricted Learn search (Simple English Wikipedia, blocked words) replaces web search for children - Simple mode (big-button home) for young children or grandparents - Child usage kept in memory instead of read from disk on every request - Tests and README updated Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aac6f6efc2
commit
301e8017d9
34 files changed
+466
-51
No files matched your search
@@ -38,6 +38,15 @@ const hub = http.createServer((req, res) => req.url.startsWith('/api/chores') ?
|
||||
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');
|
||||
// a person's own remembered device: name tiles and PIN sign-in, but not the family board or short sessions
|
||||
const cy = users.create({ name: 'Cy', role: 'user', tabs: ['chat'], handle: 'Yy' }); board.setPin(cy.id, '135790');
|
||||
assert.equal((await call('/api/login/people')).status, 403, 'a stranger gets no names');
|
||||
r = await post('/api/device/trust', {}, session(ann)); assert.equal(r.status, 200); const own = r.set.find(c => c.startsWith('hub_device=')).split(';')[0]; assert.ok(!r.set.some(c => c.startsWith('hub_session=;')), 'remembering a device does not sign you out');
|
||||
r = await call('/api/login/people', {}, own); assert.equal(r.status, 200); assert.ok(r.json.people.find(p => p.name === 'Cy').pinLen === 6 && r.json.people.find(p => p.name === 'Kai').kid, 'tiles show names, kids and PIN length');
|
||||
assert.equal((await call('/api/board', {}, own)).status, 403, 'a remembered personal device is not a family board');
|
||||
assert.equal((await post('/api/login/pin', { uid: cy.id, pin: '000000' }, own)).status, 401); r = await post('/api/login/pin', { uid: cy.id, pin: '135790' }, own); assert.equal(r.status, 200); const full = r.set.find(c => c.startsWith('hub_session=')); assert.ok(!/Max-Age=1800/.test(full) && /Max-Age=\d{7}/.test(full), 'a normal long session on your own device');
|
||||
assert.equal((await post('/api/login/pin', { uid: kid.id }, own)).status, 200, 'a child taps their name'); assert.equal((await post('/api/login/pin', { uid: bob.id }, own)).status === 200, false, 'an adult with no valid PIN cannot'); assert.equal((await post('/api/login/pin', { uid: cy.id, pin: '135790' }, '')).status, 403, 'and only from a remembered device');
|
||||
assert.equal((await post('/api/device/remove', {}, session(cy) + '; ' + own)).status, 403, 'someone else cannot forget Ann\'s device'); assert.equal((await post('/api/device/remove', {}, session(ann) + '; ' + own)).status, 200); assert.equal((await call('/api/login/people', {}, own)).status, 403, 'forgotten: the tiles are gone');
|
||||
console.log('board ok');
|
||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Parent controls for children: per-area and weekend limits, the activity log and alerts to parents, requests, bonus screen time from rewards, the weekly summary.
|
||||
const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), { Readable } = require('stream');
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-kidctl-')); process.env.HUB_TZ = 'UTC';
|
||||
const users = require('../users'), kid = require('../kidlimits'), activity = require('../activity'), inbox = require('../inbox'), requests = require('../requests'), chores = require('../chores');
|
||||
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), kai = users.create({ name: 'Kai', role: 'user', tabs: ['chat', 'chores', 'music'], handle: 'Yg' });
|
||||
users.update(kai.id, { kid: { on: true, limitMin: 120, weekendMin: 240, areaLimits: { video: 15, music: 30, bogus: 5 } } });
|
||||
const call = (who, url, method = 'GET', body, mod = requests) => new Promise(ok => { const q = Readable.from(body ? [Buffer.from(JSON.stringify(body))] : []); q.url = url; q.method = method; q.headers = {}; users.runAs(who, () => mod.handle(q, { writeHead(c) { this.c = c; }, end(d) { ok({ status: this.c, json: JSON.parse(d) }); } }, who)); });
|
||||
(async () => {
|
||||
const k = users.get(kai.id).kid; assert.deepEqual(k.areaLimits, { video: 15, music: 30 }, 'only real areas, at least 15 minutes'); assert.equal(k.weekendMin, 240);
|
||||
// per-area time: pings add to the area; the area closes by itself while the rest stays open
|
||||
let st = kid.status(users.get(kai.id)); assert.deepEqual(st.lockedAreas, []); assert.equal(st.areas.music.limit, 30);
|
||||
const realNow = Date.now; let off = 0; Date.now = () => realNow() + off; for (let i = 0; i < 40; i++) { off += 60e3; kid.ping(users.get(kai.id), 'music'); } Date.now = realNow; // (a minute passes between pings) st = kid.status(users.get(kai.id)); assert.ok(st.areas.music.used >= 30 && st.lockedAreas.includes('music'), 'music time used up'); assert.equal(st.locked, null, 'but the app as a whole is open');
|
||||
assert.equal(kid.lockedFor(users.get(kai.id), '/api/music/x').area, 'music', 'music requests are refused'); assert.equal(kid.lockedFor(users.get(kai.id), '/api/notes/'), null, 'other things still work'); assert.equal(kid.lockedFor(users.get(kai.id), '/api/read/list'), null);
|
||||
assert.ok(inbox.load(ann.id).some(i => /Music time/.test(i.body)), 'the parent is told');
|
||||
assert.equal(kid.usageByDay(kai.id, 7)[0].areas.music >= 30, true, 'the parents see minutes per area');
|
||||
// activity: logged, de-duplicated, only for children, alerts rate-limited
|
||||
activity.log(kai.id, 'play', 'Song A'); activity.log(kai.id, 'play', 'Song A'); activity.log(kai.id, 'search', 'dinosaurs'); activity.log(ann.id, 'play', 'Not a child'); assert.equal(activity.recent(kai.id).filter(e => e.title === 'Song A').length, 1, 'the same thing twice counts once'); assert.equal(activity.recent(ann.id).length, 0, 'adults are not logged');
|
||||
activity.alertParents(users.get(kai.id), 'blocked', 'tried to change the Plex profile'); const n0 = inbox.load(ann.id).length; activity.alertParents(users.get(kai.id), 'blocked', 'tried to change the Plex profile'); assert.equal(inbox.load(ann.id).length, n0, 'the same alert is not repeated');
|
||||
assert.equal((await call(ann, '/api/kid/activity?user=' + kai.id, 'GET', null, kid)).json.recent.length >= 2, true); assert.equal((await call(kai, '/api/kid/activity?user=' + kai.id, 'GET', null, kid)).status, 403, 'a child cannot read the log');
|
||||
assert.equal((await call(kai, '/api/kid/activity', 'POST', { kind: 'watch', title: 'A movie' }, kid)).status, 200); assert.ok(activity.recent(kai.id).some(e => e.title === 'A movie' && e.kind === 'watch'), 'the app reports what a child plays');
|
||||
// requests: ask, parent answers, a yes opens a tab and gives minutes, a no just tells them
|
||||
assert.equal((await call(ann, '/api/kid/requests', 'POST', { text: 'x' })).status, 400, 'only children ask'); let r = (await call(kai, '/api/kid/requests', 'POST', { text: 'Can I watch movies?', tab: 'video', minutes: 20 })).json; assert.equal(r.status, 'open'); assert.ok(inbox.load(ann.id).some(i => /Kai is asking/.test(i.title)));
|
||||
assert.equal((await call(kai, '/api/kid/requests/' + r.id, 'POST', { approve: true })).status, 403, 'a child cannot approve their own'); const before = kid.status(users.get(kai.id)).limit; assert.equal((await call(ann, '/api/kid/requests/' + r.id, 'POST', { approve: true, note: 'Weekend only' })).json.status, 'approved');
|
||||
assert.ok(users.can(users.get(kai.id), 'video'), 'a yes opens the part of the hub'); assert.equal(kid.status(users.get(kai.id)).limit, before + 20, 'and adds the minutes'); assert.ok(inbox.load(kai.id).some(i => /^Yes: /.test(i.title)), 'the child is told');
|
||||
r = (await call(kai, '/api/kid/requests', 'POST', { text: 'A new game' })).json; assert.equal((await call(ann, '/api/kid/requests/' + r.id, 'POST', { approve: false, note: 'Not now' })).json.status, 'denied'); assert.equal((await call(ann, '/api/kid/requests/' + r.id, 'POST', { approve: true })).status, 400, 'answered once');
|
||||
// a reward with bonus screen time adds it when the parent marks it given
|
||||
users.update(kai.id, { tabs: [...users.get(kai.id).tabs, 'chores'] }); await call(ann, '/api/chores', 'POST', { title: 'Dishes', repeat: 'daily', assign: 'anyone', points: 10 }, chores); const today = new Date().toISOString().slice(0, 10), ch = (await call(ann, '/api/chores?date=' + today, 'GET', null, chores)).json.chores[0];
|
||||
await call(kai, '/api/chores/done', 'POST', { chore: ch.id, date: today }, chores); const rw = (await call(ann, '/api/chores/reward', 'POST', { title: 'Extra time', points: 5, minutes: 30 }, chores)).json; assert.equal(rw.minutes, 30);
|
||||
const lim0 = kid.status(users.get(kai.id)).limit; await call(ann, '/api/chores/give', 'POST', { user: kai.id, reward: rw.id }, chores); assert.equal(kid.status(users.get(kai.id)).limit, lim0 + 30, 'the bonus minutes are added');
|
||||
// the weekly summary goes to the parents once, on Monday morning
|
||||
const mon = new Date('2026-10-26T09:00:00Z'); assert.equal(activity.weeklyDigest(new Date('2026-10-27T09:00:00Z')), false, 'only on Monday'); assert.equal(activity.weeklyDigest(mon), true); assert.ok(inbox.load(ann.id).some(i => /Kai: last week/.test(i.title))); assert.equal(activity.weeklyDigest(mon), false, 'once a week');
|
||||
console.log('kidcontrols ok'); process.exit(0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -5,7 +5,7 @@ const users = require('../users'), kid = require('../kidlimits'), chat = require
|
||||
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), sam = users.create({ name: 'Sam', role: 'user', tabs: ['music'], handle: 'Yg' }), pat = users.create({ name: 'Pat', role: 'user', tabs: ['music'], handle: 'Yw' });
|
||||
const asAdmin = (u, fn) => users.runAs(u, fn);
|
||||
assert.throws(() => users.update(ann.id, { kid: { on: true } }), /admin cannot be a child/, 'an admin cannot be a child account');
|
||||
users.update(sam.id, { kid: { on: true, limitMin: 3, bedtime: { on: false, from: '20:30', to: '07:00' } } }); const s0 = users.get(sam.id).kid; assert.deepEqual(s0, { on: true, limitMin: 15, bedtime: { on: false, from: '20:30', to: '07:00' }, safe: true }, 'a limit is at least 15 minutes; safe search is on unless turned off');
|
||||
users.update(sam.id, { kid: { on: true, limitMin: 3, bedtime: { on: false, from: '20:30', to: '07:00' } } }); const s0 = users.get(sam.id).kid; assert.deepEqual(s0, { on: true, limitMin: 15, weekendMin: 0, areaLimits: {}, bedtime: { on: false, from: '20:30', to: '07:00' }, safe: true }, 'a limit is at least 15 minutes; safe search is on unless turned off');
|
||||
assert.equal(users.publicView(users.get(sam.id)).kid.limitMin, 15); assert.equal(users.publicView(users.get(pat.id)).kid, undefined, 'only child accounts carry the settings');
|
||||
assert.deepEqual(kid.status(users.get(pat.id)), { on: false }); assert.equal(kid.lockedFor(users.get(pat.id), '/api/music/x'), null);
|
||||
// time counts from pings (one a minute while the app is open)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// The restricted search for children: blocked words (built in and added by a parent), results that mention them are hidden, and nothing but Simple English Wikipedia is asked.
|
||||
const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), { Readable } = require('stream');
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-kidsearch-'));
|
||||
const users = require('../users'), ks = require('../kidsearch'), inbox = require('../inbox');
|
||||
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), kai = users.create({ name: 'Kai', role: 'user', tabs: ['search'], handle: 'Yg' }); users.update(kai.id, { kid: { on: true } });
|
||||
const asked = []; global.fetch = async (url) => { asked.push(String(url)); const u = new URL(url), q = u.searchParams.get('gsrsearch'), t = u.searchParams.get('titles');
|
||||
const pages = q ? { 1: { title: 'Dinosaur', index: 1, extract: 'Dinosaurs lived long ago.' }, 2: { title: 'Casino', index: 2, extract: 'A place for gambling.' }, 3: { title: 'Tyrannosaurus', index: 3, extract: 'A big dinosaur with a secret word: zebra.' } } : { 9: { title: t, extract: 'Long text about ' + t } };
|
||||
return { ok: true, json: async () => ({ query: { pages } }) }; };
|
||||
const call = (who, url, method = 'GET', body) => new Promise(ok => { const q = Readable.from(body ? [Buffer.from(JSON.stringify(body))] : []); q.url = url; q.method = method; q.headers = {}; users.runAs(who, () => ks.handle(q, { writeHead(c) { this.c = c; }, end(d) { ok({ status: this.c, json: JSON.parse(d) }); } })); });
|
||||
(async () => {
|
||||
assert.ok(ks.blocked('free porn') && ks.blocked('To Kill a Mockingbird') === false && ks.blocked('sexual stuff') && !ks.blocked('Essex county') && !ks.blocked('a sextant and a class'), 'whole words only, any case');
|
||||
let r = await call(users.get(kai.id), '/api/kidsearch?q=dinosaurs'); assert.equal(r.status, 200); assert.deepEqual(r.json.results.map(x => x.title), ['Dinosaur', 'Tyrannosaurus'], 'a result that mentions a blocked word (gambling) is hidden'); assert.ok(asked.every(u => u.startsWith('https://simple.wikipedia.org/')), 'only Simple English Wikipedia is asked');
|
||||
r = await call(users.get(kai.id), '/api/kidsearch?q=porn'); assert.ok(r.json.blocked && r.json.results.length === 0, 'a blocked search is not made'); assert.equal(asked.filter(u => /porn/.test(u)).length, 0); assert.ok(inbox.load(ann.id).some(i => /searched for something blocked/.test(i.body)), 'the parent is told');
|
||||
assert.equal((await call(users.get(kai.id), '/api/kidsearch/words', 'POST', { words: 'zebra' })).status, 403, 'a child cannot change the list'); assert.deepEqual((await call(ann, '/api/kidsearch/words', 'POST', { words: 'Zebra, unicorn\nZEBRA' })).json.words, ['zebra', 'unicorn'], 'a parent adds words');
|
||||
r = await call(users.get(kai.id), '/api/kidsearch?q=dinosaurs'); assert.deepEqual(r.json.results.map(x => x.title), ['Dinosaur'], 'and results with those words go too'); assert.ok((await call(users.get(kai.id), '/api/kidsearch?q=a unicorn')).json.blocked);
|
||||
r = await call(users.get(kai.id), '/api/kidsearch/page?title=Dinosaur'); assert.equal(r.json.title, 'Dinosaur'); assert.equal((await call(users.get(kai.id), '/api/kidsearch/page?title=Casino')).status, 403, 'a blocked page cannot be opened either');
|
||||
console.log('kidsearch ok'); process.exit(0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
Reference in new issue
Block a user