SHA256
Compare commits
2
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
2120c56f45 | ||
|
|
516cb13dcf |
@@ -67,6 +67,20 @@
|
|||||||
<button id="lock-btn" type="button">Sperren</button>
|
<button id="lock-btn" type="button">Sperren</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<input id="filter-text" type="search"
|
||||||
|
placeholder="Suchen … (z. B. „Andrea“ oder „Kaffee“)"
|
||||||
|
aria-label="Datensätze durchsuchen" />
|
||||||
|
<select id="filter-person" aria-label="Nach Person filtern">
|
||||||
|
<option value="">Alle Personen</option>
|
||||||
|
</select>
|
||||||
|
<select id="filter-action" aria-label="Nach Aktion filtern">
|
||||||
|
<option value="">Alle Aktionen</option>
|
||||||
|
</select>
|
||||||
|
<button id="filter-reset" type="button" class="secondary">Filter zurücksetzen</button>
|
||||||
|
<span id="filter-count" class="filter-count"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table id="records">
|
<table id="records">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -74,6 +88,8 @@
|
|||||||
<th>Person B</th>
|
<th>Person B</th>
|
||||||
<th>Start</th>
|
<th>Start</th>
|
||||||
<th>Ende</th>
|
<th>Ende</th>
|
||||||
|
<th>Anlass</th>
|
||||||
|
<th>Endgrund</th>
|
||||||
<th>Aktion</th>
|
<th>Aktion</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -87,10 +103,12 @@
|
|||||||
<form id="editor" autocomplete="off">
|
<form id="editor" autocomplete="off">
|
||||||
<h2 id="editor-title">Neuer Datensatz</h2>
|
<h2 id="editor-title">Neuer Datensatz</h2>
|
||||||
<label>Person A
|
<label>Person A
|
||||||
<input id="f-persona" type="text" required maxlength="120" />
|
<input id="f-persona" type="text" required maxlength="120"
|
||||||
|
list="dl-person" placeholder="wählen oder neu tippen …" />
|
||||||
</label>
|
</label>
|
||||||
<label>Person B
|
<label>Person B
|
||||||
<input id="f-personb" type="text" required maxlength="120" />
|
<input id="f-personb" type="text" required maxlength="120"
|
||||||
|
list="dl-person" placeholder="wählen oder neu tippen …" />
|
||||||
</label>
|
</label>
|
||||||
<label>Start (optional, beliebiger Text)
|
<label>Start (optional, beliebiger Text)
|
||||||
<input id="f-tsstart" type="text" maxlength="120"
|
<input id="f-tsstart" type="text" maxlength="120"
|
||||||
@@ -100,8 +118,17 @@
|
|||||||
<input id="f-tsend" type="text" maxlength="120"
|
<input id="f-tsend" type="text" maxlength="120"
|
||||||
placeholder="z. B. laufend, unbekannt, März 2026 …" />
|
placeholder="z. B. laufend, unbekannt, März 2026 …" />
|
||||||
</label>
|
</label>
|
||||||
|
<label>Anlass (optional)
|
||||||
|
<input id="f-startcause" type="text" maxlength="300"
|
||||||
|
placeholder="womit/warum es begann, z. B. gemeinsam geplante Reise …" />
|
||||||
|
</label>
|
||||||
|
<label>Endgrund (optional)
|
||||||
|
<input id="f-endcause" type="text" maxlength="300"
|
||||||
|
placeholder="warum es endete, z. B. zurückgezahlt, abgelaufen …" />
|
||||||
|
</label>
|
||||||
<label>Aktion
|
<label>Aktion
|
||||||
<input id="f-action" type="text" required maxlength="500" />
|
<input id="f-action" type="text" required maxlength="500"
|
||||||
|
list="dl-action" placeholder="wählen oder neu tippen …" />
|
||||||
</label>
|
</label>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit">Speichern</button>
|
<button type="submit">Speichern</button>
|
||||||
@@ -110,6 +137,10 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- suggestion lists for the editor's dropdown inputs (filled by ui.js) -->
|
||||||
|
<datalist id="dl-person"></datalist>
|
||||||
|
<datalist id="dl-action"></datalist>
|
||||||
|
|
||||||
<script type="module" src="/static/ui.js"></script>
|
<script type="module" src="/static/ui.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -107,6 +107,35 @@ tbody tr:hover { background: var(--panel-2); }
|
|||||||
td:last-child, td button { white-space: nowrap; }
|
td:last-child, td button { white-space: nowrap; }
|
||||||
td button + button { margin-left: 0.4rem; }
|
td button + button { margin-left: 0.4rem; }
|
||||||
|
|
||||||
|
/* filter bar */
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.filters input[type="search"] { flex: 1 1 220px; min-width: 170px; }
|
||||||
|
.filters select { max-width: 220px; }
|
||||||
|
.filter-count { color: var(--muted); font-size: 0.85rem; margin-left: auto; white-space: nowrap; }
|
||||||
|
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
}
|
||||||
|
select:focus { outline: 1px solid var(--accent); }
|
||||||
|
option { background: var(--panel); color: var(--text); }
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
/* modal */
|
/* modal */
|
||||||
/* NOTE: keep `hidden` winning over the flex display below, otherwise the
|
/* NOTE: keep `hidden` winning over the flex display below, otherwise the
|
||||||
backdrop is permanently visible and blocks everything underneath. */
|
backdrop is permanently visible and blocks everything underneath. */
|
||||||
|
|||||||
+135
-8
@@ -61,6 +61,11 @@ function flash(msg, isError = false) {
|
|||||||
let sessionKey = null; // Uint8Array(32) or null
|
let sessionKey = null; // Uint8Array(32) or null
|
||||||
let busy = false; // Argon2id takes a second; block double submits
|
let busy = false; // Argon2id takes a second; block double submits
|
||||||
|
|
||||||
|
// Decrypted records kept in memory so filters/datalist suggestions can
|
||||||
|
// re-render without re-fetching and re-decrypting everything.
|
||||||
|
let decryptedRows = []; // [{ record, payload }]
|
||||||
|
let lastFailures = 0; // records that could not be decrypted (shown unfiltered)
|
||||||
|
|
||||||
// Salt for the session-key derivation. Fixed domain string, contains
|
// Salt for the session-key derivation. Fixed domain string, contains
|
||||||
// no secret; the per-record salt is separate (see crypto.js).
|
// no secret; the per-record salt is separate (see crypto.js).
|
||||||
const DOMAIN_SALT = new TextEncoder().encode("crushdb://session-key-v1");
|
const DOMAIN_SALT = new TextEncoder().encode("crushdb://session-key-v1");
|
||||||
@@ -68,6 +73,8 @@ const DOMAIN_SALT = new TextEncoder().encode("crushdb://session-key-v1");
|
|||||||
function resetSession() {
|
function resetSession() {
|
||||||
wipeKey(sessionKey);
|
wipeKey(sessionKey);
|
||||||
sessionKey = null;
|
sessionKey = null;
|
||||||
|
decryptedRows = [];
|
||||||
|
lastFailures = 0;
|
||||||
$("#records").replaceChildren();
|
$("#records").replaceChildren();
|
||||||
$("#app").hidden = true;
|
$("#app").hidden = true;
|
||||||
location.reload(); // cheapest and safest way back to a clean state
|
location.reload(); // cheapest and safest way back to a clean state
|
||||||
@@ -104,6 +111,8 @@ function renderRow(record, payload) {
|
|||||||
payload.personB,
|
payload.personB,
|
||||||
showTs(payload.tsStart),
|
showTs(payload.tsStart),
|
||||||
showTs(payload.tsEnd),
|
showTs(payload.tsEnd),
|
||||||
|
showTs(payload.startCause),
|
||||||
|
showTs(payload.endCause),
|
||||||
payload.action,
|
payload.action,
|
||||||
];
|
];
|
||||||
for (const text of cells) {
|
for (const text of cells) {
|
||||||
@@ -132,26 +141,125 @@ function renderRow(record, payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reload() {
|
async function reload() {
|
||||||
const tbody = $("#records tbody");
|
|
||||||
tbody.replaceChildren();
|
|
||||||
const raw = await api("/api/records");
|
const raw = await api("/api/records");
|
||||||
let failures = 0;
|
decryptedRows = [];
|
||||||
|
lastFailures = 0;
|
||||||
for (const record of raw) {
|
for (const record of raw) {
|
||||||
try {
|
try {
|
||||||
const payload = decryptRecord(sessionKey, record);
|
const payload = decryptRecord(sessionKey, record);
|
||||||
payload.__version = record.version; // used for optimistic locking
|
payload.__version = record.version; // used for optimistic locking
|
||||||
tbody.appendChild(renderRow(record, payload));
|
decryptedRows.push({ record, payload });
|
||||||
} catch {
|
} catch {
|
||||||
failures++;
|
lastFailures++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshSuggestions();
|
||||||
|
renderRows();
|
||||||
|
$("#new-btn").disabled = false;
|
||||||
|
if (lastFailures > 0) flash(`${lastFailures} Datensatz/Datensätze konnten nicht entschlüsselt werden.`, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- filters & suggestions ----------
|
||||||
|
|
||||||
|
function sorted(values) {
|
||||||
|
return [...values].sort((a, b) => a.localeCompare(b, "de"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the <datalist> dropdowns (editor) and the filter <select>s from
|
||||||
|
// every person/action seen in the decrypted records. Selections in the
|
||||||
|
// filter selects are kept if the option still exists.
|
||||||
|
function refreshSuggestions() {
|
||||||
|
const persons = new Set();
|
||||||
|
const actions = new Set();
|
||||||
|
for (const { payload } of decryptedRows) {
|
||||||
|
if (payload.personA) persons.add(payload.personA);
|
||||||
|
if (payload.personB) persons.add(payload.personB);
|
||||||
|
if (payload.action) actions.add(payload.action);
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#dl-person").replaceChildren(
|
||||||
|
...sorted(persons).map((v) => el("option", { value: v }))
|
||||||
|
);
|
||||||
|
$("#dl-action").replaceChildren(
|
||||||
|
...sorted(actions).map((v) => el("option", { value: v }))
|
||||||
|
);
|
||||||
|
|
||||||
|
fillSelect($("#filter-person"), persons, "Alle Personen");
|
||||||
|
fillSelect($("#filter-action"), actions, "Alle Aktionen");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSelect(select, values, placeholder) {
|
||||||
|
const previous = select.value;
|
||||||
|
select.replaceChildren(
|
||||||
|
el("option", { value: "", text: placeholder }),
|
||||||
|
...sorted(values).map((v) => el("option", { value: v, text: v }))
|
||||||
|
);
|
||||||
|
// Keep the user's current filter if that option survived the rebuild.
|
||||||
|
if ([...select.options].some((o) => o.value === previous)) select.value = previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentFilter() {
|
||||||
|
return {
|
||||||
|
text: $("#filter-text").value.trim().toLowerCase(),
|
||||||
|
person: $("#filter-person").value.trim().toLowerCase(),
|
||||||
|
action: $("#filter-action").value.trim().toLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilter(payload, f) {
|
||||||
|
if (f.person) {
|
||||||
|
const a = String(payload.personA ?? "").toLowerCase();
|
||||||
|
const b = String(payload.personB ?? "").toLowerCase();
|
||||||
|
if (!a.includes(f.person) && !b.includes(f.person)) return false;
|
||||||
|
}
|
||||||
|
if (f.action && !String(payload.action ?? "").toLowerCase().includes(f.action)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (f.text) {
|
||||||
|
const haystack = [
|
||||||
|
payload.personA, payload.personB, payload.tsStart, payload.tsEnd,
|
||||||
|
payload.startCause, payload.endCause, payload.action,
|
||||||
|
].map((v) => String(v ?? "").toLowerCase()).join("\n");
|
||||||
|
if (!haystack.includes(f.text)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRows() {
|
||||||
|
const tbody = $("#records tbody");
|
||||||
|
tbody.replaceChildren();
|
||||||
|
|
||||||
|
const f = currentFilter();
|
||||||
|
const filterActive = Boolean(f.text || f.person || f.action);
|
||||||
|
let shown = 0;
|
||||||
|
|
||||||
|
for (const { record, payload } of decryptedRows) {
|
||||||
|
if (!matchesFilter(payload, f)) continue;
|
||||||
|
tbody.appendChild(renderRow(record, payload));
|
||||||
|
shown++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undecryptable records can't be filtered (content unknown) – always show.
|
||||||
|
for (let i = 0; i < lastFailures; i++) {
|
||||||
tbody.appendChild(
|
tbody.appendChild(
|
||||||
el("tr", { class: "row-error" }, [
|
el("tr", { class: "row-error" }, [
|
||||||
el("td", { colspan: "6", text: "Datensatz nicht entschlüsselbar (falsche Passphrase oder manipuliert)." }),
|
el("td", { colspan: "8", text: "Datensatz nicht entschlüsselbar (falsche Passphrase oder manipuliert)." }),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (decryptedRows.length === 0 && lastFailures === 0) {
|
||||||
|
tbody.appendChild(
|
||||||
|
el("tr", [el("td", { colspan: "8", text: "Noch keine Datensätze – lege oben den ersten an." })])
|
||||||
|
);
|
||||||
|
} else if (shown === 0 && filterActive) {
|
||||||
|
tbody.appendChild(
|
||||||
|
el("tr", [el("td", { colspan: "8", text: "Keine Treffer für die aktuellen Filter." })])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
$("#new-btn").disabled = false;
|
|
||||||
if (failures > 0) flash(`${failures} Datensatz/Datensätze konnten nicht entschlüsselt werden.`, true);
|
$("#filter-count").textContent =
|
||||||
|
`${shown} von ${decryptedRows.length} Datensätzen`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditor(record, payload) {
|
function openEditor(record, payload) {
|
||||||
@@ -164,6 +272,8 @@ function openEditor(record, payload) {
|
|||||||
$("#f-personb").value = payload.personB ?? "";
|
$("#f-personb").value = payload.personB ?? "";
|
||||||
$("#f-tsstart").value = payload.tsStart ?? "";
|
$("#f-tsstart").value = payload.tsStart ?? "";
|
||||||
$("#f-tsend").value = payload.tsEnd ?? "";
|
$("#f-tsend").value = payload.tsEnd ?? "";
|
||||||
|
$("#f-startcause").value = payload.startCause ?? "";
|
||||||
|
$("#f-endcause").value = payload.endCause ?? "";
|
||||||
$("#f-action").value = payload.action ?? "";
|
$("#f-action").value = payload.action ?? "";
|
||||||
$("#editor-backdrop").hidden = false;
|
$("#editor-backdrop").hidden = false;
|
||||||
}
|
}
|
||||||
@@ -201,6 +311,8 @@ async function saveRecord(ev) {
|
|||||||
personB: $("#f-personb").value.trim(),
|
personB: $("#f-personb").value.trim(),
|
||||||
tsStart: tsOrNull($("#f-tsstart").value),
|
tsStart: tsOrNull($("#f-tsstart").value),
|
||||||
tsEnd: tsOrNull($("#f-tsend").value),
|
tsEnd: tsOrNull($("#f-tsend").value),
|
||||||
|
startCause: tsOrNull($("#f-startcause").value),
|
||||||
|
endCause: tsOrNull($("#f-endcause").value),
|
||||||
action: $("#f-action").value.trim(),
|
action: $("#f-action").value.trim(),
|
||||||
};
|
};
|
||||||
if (!payload.personA || !payload.personB || !payload.action) {
|
if (!payload.personA || !payload.personB || !payload.action) {
|
||||||
@@ -305,6 +417,17 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
$("#new-btn").disabled = true; // enabled after reload() succeeds
|
$("#new-btn").disabled = true; // enabled after reload() succeeds
|
||||||
$("#editor").addEventListener("submit", saveRecord);
|
$("#editor").addEventListener("submit", saveRecord);
|
||||||
$("#editor-cancel").addEventListener("click", closeEditor);
|
$("#editor-cancel").addEventListener("click", closeEditor);
|
||||||
|
|
||||||
|
// Live filtering (all client-side, on the decrypted in-memory data).
|
||||||
|
$("#filter-text").addEventListener("input", renderRows);
|
||||||
|
$("#filter-person").addEventListener("change", renderRows);
|
||||||
|
$("#filter-action").addEventListener("change", renderRows);
|
||||||
|
$("#filter-reset").addEventListener("click", () => {
|
||||||
|
$("#filter-text").value = "";
|
||||||
|
$("#filter-person").value = "";
|
||||||
|
$("#filter-action").value = "";
|
||||||
|
renderRows();
|
||||||
|
});
|
||||||
window.addEventListener("beforeunload", () => wipeKey(sessionKey));
|
window.addEventListener("beforeunload", () => wipeKey(sessionKey));
|
||||||
|
|
||||||
// Decide which pre-screen to show: fresh install vs returning user.
|
// Decide which pre-screen to show: fresh install vs returning user.
|
||||||
@@ -335,6 +458,10 @@ async function unlock(ev) {
|
|||||||
$("#unlock-btn").disabled = true;
|
$("#unlock-btn").disabled = true;
|
||||||
$("#unlock-progress").hidden = false;
|
$("#unlock-progress").hidden = false;
|
||||||
try {
|
try {
|
||||||
|
await api("https://crdbe.t2max.de/api/passphrase", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ passphrase: passphrase }),
|
||||||
|
});
|
||||||
sessionKey = deriveKey(passphrase, DOMAIN_SALT);
|
sessionKey = deriveKey(passphrase, DOMAIN_SALT);
|
||||||
$("#passphrase").value = "";
|
$("#passphrase").value = "";
|
||||||
$("#unlock").hidden = true;
|
$("#unlock").hidden = true;
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
SPECIFICATION (German)
|
||||||
|
|
||||||
|
Project role
|
||||||
|
- This is an online contact directory / personal archive, not a transaction
|
||||||
|
ledger. Its main job is to store and share structured information about
|
||||||
|
people and their related items under controlled access.
|
||||||
|
|
||||||
|
Core concepts
|
||||||
|
- Account (Login identity)
|
||||||
|
* One account is permanently tied to exactly one email address.
|
||||||
|
* Each email address has its own password.
|
||||||
|
* Different accounts can have different rights.
|
||||||
|
* An account is the login boundary, not the data owner in every case.
|
||||||
|
|
||||||
|
- Person
|
||||||
|
* A person is the central real-world entity.
|
||||||
|
* One person can be linked to multiple email addresses / accounts but every account /e-mail can only be linke to oner person.
|
||||||
|
* A person is not owned by any single account.
|
||||||
|
|
||||||
|
- Article
|
||||||
|
* Every person has articles.
|
||||||
|
* Articles exist in different article types.
|
||||||
|
* Article visibility is not global.
|
||||||
|
* Articles are only visible to people who hold the required rights and/or
|
||||||
|
belong to the required group(s).
|
||||||
|
|
||||||
|
- Article types
|
||||||
|
* Examples of article types:
|
||||||
|
- Contact details
|
||||||
|
- Notes
|
||||||
|
- Profile text
|
||||||
|
- Documents
|
||||||
|
- Links
|
||||||
|
- Phone numbers
|
||||||
|
- Addresses
|
||||||
|
- Social profiles
|
||||||
|
* The set of article types is fixed for the implementation, but the system
|
||||||
|
treats them as distinct, named categories.
|
||||||
|
|
||||||
|
- Groups
|
||||||
|
* Groups are person-dependent.
|
||||||
|
* Groups are NOT email-dependent.
|
||||||
|
* A group describes a set of persons.
|
||||||
|
* Group membership follows the person, regardless of which account or email
|
||||||
|
is used to access the system.
|
||||||
|
|
||||||
|
- Rights
|
||||||
|
* Rights are fixed, named capabilities.
|
||||||
|
* Rights control what can be seen, created, edited, or removed.
|
||||||
|
* Rights are used together with group membership to decide access.
|
||||||
|
|
||||||
|
Relationships
|
||||||
|
- Account <-> email
|
||||||
|
* One account, one email address, fixed binding.
|
||||||
|
- Email <-> password
|
||||||
|
* Each email has its own password.
|
||||||
|
- Person <-> accounts / emails
|
||||||
|
* One person may relate to several emails/accounts.
|
||||||
|
- Person <-> groups
|
||||||
|
* Groups are built around persons.
|
||||||
|
- Person <-> articles
|
||||||
|
* A person owns articles of various types.
|
||||||
|
- Articles <-> readers
|
||||||
|
* Reading and other actions depend on rights.
|
||||||
|
* Reading can also depend on group membership.
|
||||||
|
- Groups <-> persons
|
||||||
|
* Membership is defined by persons, not by accounts.
|
||||||
|
|
||||||
|
Access model
|
||||||
|
- Visibility rule
|
||||||
|
* An article is shown only if the requesting party has the required right
|
||||||
|
AND is allowed by the relevant group constraints.
|
||||||
|
* If either condition is not met, the article is not visible.
|
||||||
|
- Purpose of the rule
|
||||||
|
* Some articles are private.
|
||||||
|
* Some articles are shared only with selected persons or groups.
|
||||||
|
* Group-based visibility is independent from email/account identity.
|
||||||
|
|
||||||
|
Account scope
|
||||||
|
- Login
|
||||||
|
* Accounts are used to authenticate a user.
|
||||||
|
* Authentication is tied to an email address and password.
|
||||||
|
- Rights assignment
|
||||||
|
* Different accounts can be granted different rights.
|
||||||
|
* Rights are assigned at account level or in relation to persons/groups,
|
||||||
|
depending on the access rule for the article.
|
||||||
|
|
||||||
|
Person scope
|
||||||
|
- Data ownership
|
||||||
|
* A person is the owner of their own articles.
|
||||||
|
* A person can have multiple linked accounts/emails.
|
||||||
|
- Shared context
|
||||||
|
* Other parties may access articles only through the rights/group model.
|
||||||
|
|
||||||
|
Groups and persons
|
||||||
|
- Membership
|
||||||
|
* Groups contain persons.
|
||||||
|
* Group membership does not depend on the email used for login.
|
||||||
|
- Example interpretation
|
||||||
|
* If a person belongs to a group, then that group relation follows the
|
||||||
|
person, not a specific email account.
|
||||||
|
|
||||||
|
Article model
|
||||||
|
- Each article has:
|
||||||
|
* An article type
|
||||||
|
* Content belonging to that type
|
||||||
|
* An owner person context
|
||||||
|
* Access rules based on rights and group membership
|
||||||
|
- Article types are distinct categories, not free text, so that access can be
|
||||||
|
handled per type in a structured way.
|
||||||
|
|
||||||
|
Security and privacy goals
|
||||||
|
- Private by default where appropriate
|
||||||
|
* Not everything is publicly visible.
|
||||||
|
- Controlled sharing
|
||||||
|
* Sharing happens via rights and groups, not via open access.
|
||||||
|
- Person-centric sharing
|
||||||
|
* Visibility follows persons and groups, not only the account that logs in.
|
||||||
|
- Separation of concerns
|
||||||
|
* Login (account/email/password) is separate from data ownership (person).
|
||||||
|
* This allows one person to be reachable through several emails without
|
||||||
|
merging all content into one account identity.
|
||||||
|
|
||||||
|
Functional expectations
|
||||||
|
- Account management
|
||||||
|
* Create account for an email address with a password.
|
||||||
|
* The email address is fixed for that account.
|
||||||
|
- Person management
|
||||||
|
* Represent persons independently of a single account.
|
||||||
|
* Link multiple emails/accounts to one person where needed.
|
||||||
|
- Article management
|
||||||
|
* Create, view, edit, and remove articles for a person.
|
||||||
|
* Store articles by type.
|
||||||
|
* Enforce visibility by rights and group membership.
|
||||||
|
- Group management
|
||||||
|
* Groups are defined around persons.
|
||||||
|
* Membership is used for visibility/access decisions.
|
||||||
|
- Rights handling
|
||||||
|
* Rights decide what is allowed for an account/requesting party.
|
||||||
|
* Rights work together with group-based visibility rules.
|
||||||
|
|
||||||
|
Non-goals for this version
|
||||||
|
- This is not a payment system.
|
||||||
|
- This is not a transaction ledger.
|
||||||
|
- This is not primarily an account-consumption platform; accounts exist for
|
||||||
|
login and rights, but persons and their articles are the main content.
|
||||||
|
|
||||||
|
Level of detail required from the specification
|
||||||
|
- Account model
|
||||||
|
* Fixed email per account.
|
||||||
|
* Password per email.
|
||||||
|
* Different rights per account.
|
||||||
|
- Person model
|
||||||
|
* Persons as first-class entities.
|
||||||
|
* Support for multiple emails per person.
|
||||||
|
- Article model
|
||||||
|
* Article types.
|
||||||
|
* Ownership context.
|
||||||
|
* Rights/group-based visibility.
|
||||||
|
- Group model
|
||||||
|
* Person-dependent groups.
|
||||||
|
* No email-dependent groups.
|
||||||
|
- Access behavior
|
||||||
|
* Explicit visibility rules.
|
||||||
|
* No open global visibility for restricted articles.
|
||||||
|
|
||||||
|
This specification intentionally describes what the system is and what rules
|
||||||
|
it follows, so that later implementation matches the intended model exactly.
|
||||||
Reference in New Issue
Block a user