The family gate (decisions 63/64, R-780): family members with their own logins, a permanent forwardAuth door per family app, anchored exceptions, min_controller
gates / gates (push) Successful in 27s

- internal/family: the family list (bcrypt, generated 4x4 passwords shown once) + 30-day sessions in family.json
  (0600, atomic); a reset (generation), a removal or a logout ends sessions at the next request.
- internal/stacks/family_gate.go: family_gate / family_gate_except / min_controller in .felhom.yml; the door is written
  BEFORE the first start (install and a removed app's restore), a life record in app.yaml, reconciled by the gate loop;
  priority below the install hold, setup gate and sign-up block; every exception anchored ^/prefix(/|$) (finding F1).
- internal/web/family_gate.go: forwardAuth /__felhom_gate/family (app cookie felhom_famgate, host-only, names a store
  session); /__family/start|login|logout on the dashboard host (session cookie felhom_family, Path=/__family);
  sign-in counted per visitor (clientIP) AND per name, short windows; the household's dashboard session vouches.
  RequireAuth never reads a family cookie. The "Család" card on the security page: add / new password / remove.
Red-proofs RP-F1..RP-F7 (felhom.eu audits/family-gate-2026-10-02/A/).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-02 07:42:47 +02:00
parent a4a753b9e2
commit 977665d8c0
25 changed files with 2168 additions and 10 deletions
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>Grimmory — Családi belépés</title>
<link rel="stylesheet" href="/static/style.css?v=test">
</head>
<body class="login-body">
<div class="login-card">
<img src="/static/felhom-logo.svg?v=test" alt="Felhom.eu" class="login-logo">
<h1 class="login-title">Grimmory</h1>
<p id="family-gate-text">Ezt az alkalmazást a családi neveddel és jelszavaddal nyithatod meg.</p>
<form method="post" action="/__family/login" class="login-form">
<input type="hidden" name="_ft" value="FT">
<input type="hidden" name="rd" value="https://books.example.hu/">
<div class="form-group">
<label for="family-name">Neved</label>
<input type="text" id="family-name" name="name" autocomplete="username" autocapitalize="none" required autofocus class="form-control">
</div>
<div class="form-group">
<label for="family-password">Jelszavad</label>
<input type="password" id="family-password" name="password" autocomplete="current-password" required class="form-control">
</div>
<button type="submit" class="btn btn-primary btn-full">Belépés</button>
</form>
<p class="login-footer">books.example.hu</p>
<div class="shell-lang"><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details></div>
</div>
</body>
</html>
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>Családi belépés</title>
<link rel="stylesheet" href="/static/style.css?v=test">
</head>
<body class="login-body">
<div class="login-card">
<img src="/static/felhom-logo.svg?v=test" alt="Felhom.eu" class="login-logo">
<p class="alert alert-info" id="family-gate-notice">Beléptél. Nyisd meg újra az alkalmazást.</p>
<p id="family-gate-signed-in">Be vagy lépve a családi fiókoddal.</p>
<form method="post" action="/__family/logout">
<button type="submit" class="btn btn-outline btn-full">Kilépés</button>
</form>
<div class="shell-lang"><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details></div>
</div>
</body>
</html>
@@ -562,6 +562,64 @@ function openDialog(opts){
</script>
<div class="settings-card" id="family-card">
<h3>Család</h3>
<p class="settings-card-desc">A családtagok a saját nevükkel és jelszavukkal lépnek be a családi kapus alkalmazásokba. Ezzel a vezérlőpultot nem érik el.</p>
<div id="family-list" class="settings-grid"></div>
<p id="family-none" class="form-hint" style="display:none">Még nincs családtag.</p>
<div id="family-pw" class="alert alert-info" style="display:none">
A jelszó csak most látszik. Írd fel, vagy add át a családtagnak: <strong id="family-pw-name"></strong> — <span id="family-pw-value" class="mono"></span>
</div>
<form id="family-add" class="inline-form" onsubmit="familyAct(event, 'add', document.getElementById('family-new').value)">
<input type="text" id="family-new" class="form-control" placeholder="pl. anna" autocapitalize="none" required>
<button type="submit" class="btn btn-sm btn-primary">Családtag hozzáadása</button>
</form>
<span id="family-err" class="form-hint" style="display:none;color:var(--red)"></span>
</div>
<script>
(function () {
var T = {reset: 'Új jelszó', remove: 'Eltávolítás',
cReset: 'Új jelszót adsz? A régi azonnal megszűnik.', cRemove: 'Eltávolítod? A belépései azonnal megszűnnek.', err: 'Nem sikerült. Próbáld újra.'};
function render(members) {
var list = document.getElementById('family-list');
list.textContent = '';
document.getElementById('family-none').style.display = members.length ? 'none' : '';
members.forEach(function (n) {
var row = document.createElement('div'); row.className = 'settings-row'; row.setAttribute('data-family-member', n);
var label = document.createElement('span'); label.className = 'settings-label mono'; label.textContent = n;
var val = document.createElement('span'); val.className = 'settings-value';
[['reset', T.reset, T.cReset, 'btn-outline'], ['remove', T.remove, T.cRemove, 'btn-danger']].forEach(function (a) {
var b = document.createElement('button'); b.type = 'button'; b.className = 'btn btn-xs ' + a[3]; b.textContent = a[1];
b.addEventListener('click', function () { felhomConfirm(b, a[2], function () { familyAct(null, a[0], n); }); });
val.appendChild(b);
});
row.appendChild(label); row.appendChild(val); list.appendChild(row);
});
}
window.familyAct = function (e, action, name) {
if (e) e.preventDefault();
var err = document.getElementById('family-err'); err.style.display = 'none';
var body = new URLSearchParams(); body.set('name', name);
fetch('/family/members/' + action, {method: 'POST', credentials: 'same-origin',
headers: {'X-CSRF-Token': 'tok', 'Content-Type': 'application/x-www-form-urlencoded'}, body: body})
.then(function (r) { return r.json(); }).then(function (j) {
if (!j.ok) { err.textContent = j.error || T.err; err.style.display = 'inline'; return; }
render(j.data.members || []);
var box = document.getElementById('family-pw');
if (j.data.password) {
document.getElementById('family-pw-name').textContent = j.data.name;
document.getElementById('family-pw-value').textContent = j.data.password;
box.style.display = '';
} else { box.style.display = 'none'; }
if (action === 'add') document.getElementById('family-new').value = '';
}).catch(function () { err.textContent = T.err; err.style.display = 'inline'; });
};
fetch('/family/members', {credentials: 'same-origin'}).then(function (r) { return r.json(); })
.then(function (j) { if (j.ok) render(j.data.members || []); });
})();
</script>
<div class="settings-card">
<h3>Vészhelyzeti információk</h3>
@@ -493,6 +493,64 @@ function openDialog(opts){
</script>
<div class="settings-card" id="family-card">
<h3>Család</h3>
<p class="settings-card-desc">A családtagok a saját nevükkel és jelszavukkal lépnek be a családi kapus alkalmazásokba. Ezzel a vezérlőpultot nem érik el.</p>
<div id="family-list" class="settings-grid"></div>
<p id="family-none" class="form-hint" style="display:none">Még nincs családtag.</p>
<div id="family-pw" class="alert alert-info" style="display:none">
A jelszó csak most látszik. Írd fel, vagy add át a családtagnak: <strong id="family-pw-name"></strong> — <span id="family-pw-value" class="mono"></span>
</div>
<form id="family-add" class="inline-form" onsubmit="familyAct(event, 'add', document.getElementById('family-new').value)">
<input type="text" id="family-new" class="form-control" placeholder="pl. anna" autocapitalize="none" required>
<button type="submit" class="btn btn-sm btn-primary">Családtag hozzáadása</button>
</form>
<span id="family-err" class="form-hint" style="display:none;color:var(--red)"></span>
</div>
<script>
(function () {
var T = {reset: 'Új jelszó', remove: 'Eltávolítás',
cReset: 'Új jelszót adsz? A régi azonnal megszűnik.', cRemove: 'Eltávolítod? A belépései azonnal megszűnnek.', err: 'Nem sikerült. Próbáld újra.'};
function render(members) {
var list = document.getElementById('family-list');
list.textContent = '';
document.getElementById('family-none').style.display = members.length ? 'none' : '';
members.forEach(function (n) {
var row = document.createElement('div'); row.className = 'settings-row'; row.setAttribute('data-family-member', n);
var label = document.createElement('span'); label.className = 'settings-label mono'; label.textContent = n;
var val = document.createElement('span'); val.className = 'settings-value';
[['reset', T.reset, T.cReset, 'btn-outline'], ['remove', T.remove, T.cRemove, 'btn-danger']].forEach(function (a) {
var b = document.createElement('button'); b.type = 'button'; b.className = 'btn btn-xs ' + a[3]; b.textContent = a[1];
b.addEventListener('click', function () { felhomConfirm(b, a[2], function () { familyAct(null, a[0], n); }); });
val.appendChild(b);
});
row.appendChild(label); row.appendChild(val); list.appendChild(row);
});
}
window.familyAct = function (e, action, name) {
if (e) e.preventDefault();
var err = document.getElementById('family-err'); err.style.display = 'none';
var body = new URLSearchParams(); body.set('name', name);
fetch('/family/members/' + action, {method: 'POST', credentials: 'same-origin',
headers: {'X-CSRF-Token': 'tok', 'Content-Type': 'application/x-www-form-urlencoded'}, body: body})
.then(function (r) { return r.json(); }).then(function (j) {
if (!j.ok) { err.textContent = j.error || T.err; err.style.display = 'inline'; return; }
render(j.data.members || []);
var box = document.getElementById('family-pw');
if (j.data.password) {
document.getElementById('family-pw-name').textContent = j.data.name;
document.getElementById('family-pw-value').textContent = j.data.password;
box.style.display = '';
} else { box.style.display = 'none'; }
if (action === 'add') document.getElementById('family-new').value = '';
}).catch(function () { err.textContent = T.err; err.style.display = 'inline'; });
};
fetch('/family/members', {credentials: 'same-origin'}).then(function (r) { return r.json(); })
.then(function (j) { if (j.ok) render(j.data.members || []); });
})();
</script>
</main>