ident
All repositories: gitoria
6.6 KB
// lib/accounts.hl — THE ACCOUNT and ITS SESSIONS (piece 1, ticket #24; hardening ticket #2; sign out everywhere// #19): the accounts table and every write to it, signing a session in, reading the account a session carries,// signing out everywhere, the time zone. Statics only, the server realm.// Concept: CONCEPT.md (the creator's; source of truth).//// CREATOR'S CONVENTIONS (2026-09-24, ticket ident #10 / old #39, mission 009): every// primary key is the mpackdb UUID (`@id`, a 12-char string like '0mufbkwhlpjq'), never a// `*id` counter; the files live in storage/mpackdb/<table>.*. The public ids ARE these// UUIDs (accounts, identities, apps). Order never comes from key order: "oldest first"// sorts by the stored `created` time (then the id, only to break a tie).// Rows migrated from the old `*id` store (tools/migrate-009.hl) carry `oldId` = the old// public id (old mpackdb id + 1) — only the migration reads it.//// accountsTable pk @id index !email { email (lowercased), timeZone, created, sessionEpoch }//// NO REGISTRATION: the first right code for an address (lib/login.hl checkCode) creates the account AND its// default identity (createAccount).import { MPackDB } from 'hl:mpackdb'import { now } from 'hl:time'import { listDir, readFile, remove } from 'hl:fs'import { dir, envNumber, first, merged, isId, normEmail, checkTimeZone } from './util.hl'import { addDefaultIdentity } from './identities.hl'static accountsTable = new MPackDB(file = dir + '/accounts.db', primaryKey = '@id', indexes = ['!email'])// A SIGNED-IN SESSION'S OWN EXPIRY (ticket #2, hardening): independent of the hl:web// session file's rolling idle/maxAge, so a browser left open cannot stay signed in to// ident forever — the account's OTP must be proven again after this many ms.static sessionUserTtl = envNumber('IDENT_SESSION_TTL_MS', 1209600000) // 14 days// PUBLIC ACCOUNT / IDENTITY IDS are the records' UUIDsstatic accountRowOf = (a) => { return a == null ? null : { id = a.id email = a.email timeZone = a.timeZone } }static accountById = (id) => { return isId(id) ? accountRowOf(accountsTable.fetch(id)) : null }static accountByEmail = (email) => { return accountRowOf(first(accountsTable.find('email', normEmail(email)))) }// THE FIRST LOGIN of an address: the account (the browser's time zone; a missing or malformed one is// stored as UTC, it can be changed in ident) and THE DEFAULT IDENTITY, from the beginning. Answers the account row.static createAccount = (e, timeZone) => {tz = checkTimeZone(timeZone)id = accountsTable.put({ email = e timeZone = tz.error == null ? tz.timeZone : 'UTC' created = now() })addDefaultIdentity(id)return accountRowOf(accountsTable.fetch(id))}// the account a session carries, re-read from the table (null when signed out or gone).// ONLY A REAL SESSION COUNTS: a face's trailing `session` argument is positional, so a// hand-made emit with one argument too many would hand the face its own object in the// session's place (see README "Lessons"); the framework's session is a class instance (hlTypeName 'Instance'), JSON never is.// SIGNS A SESSION IN (ticket #2): stamps `since` (this session's own expiry, sessionUserTtl)// and `epoch` (the account's current sessionEpoch — "sign out everywhere" bumps it, which// makes every session that still carries the old epoch read as signed out, see below).// `&session` on every lambda here that writes the session: since hybriel #48 a lambda parameter COPIES its// argument (the framework Session instance included), so without `&` the write lands on a copy.static accountEpoch = (a) => { return a.sessionEpoch == null ? 0 : a.sessionEpoch }static beginSession = (&session, accountId) => {if (session == null) { return false }a = isId(accountId) ? accountsTable.fetch(accountId) : nullif (a == null) { return false }session.user = { id = accountId since = now() epoch = accountEpoch(a) }return true}static accountOfSession = (&session) => {if (session == null || session.user == null) { return null }a = accountsTable.fetch(session.user.id)if (a == null) { session.user = null return null }// AN OLDER SESSION (before ticket #2, incl. one carried across the mission-009// migration): it has no `since`/`epoch` yet. Adopt them now instead of forcing// everyone signed out the moment this ships — its own-expiry clock starts here.if (session.user.since == null || session.user.epoch == null) {session.user = { id = session.user.id since = now() epoch = accountEpoch(a) }return accountRowOf(a)}if (now() - session.user.since > sessionUserTtl || session.user.epoch != accountEpoch(a)) {session.user = nullreturn null}return accountRowOf(a)}// "SIGN OUT EVERYWHERE": bumps the account's sessionEpoch, so every session that carries// this account (this one included — the caller's UI resets the same as a normal sign out)// stops working at its next check, on every device, without touching any other session file.// The session files themselves are DELETED too (project.hl hands in `dropUser` = dropUserSessions// below on its server); the epoch stays as the second net for any session the sweep did not see.static sessionHooks = { dropUser = null }static signOutEverywhere = (accountId) => {a = isId(accountId) ? accountsTable.fetch(accountId) : nullif (a == null) { return { error = 'no such account' } }if (sessionHooks.dropUser != null) { sessionHooks.dropUser(accountId) }accountsTable.update(a.id, merged(a, { sessionEpoch = accountEpoch(a) + 1 }))return { ok = true }}// ---- DELETE EVERY SESSION OF AN ACCOUNT (ticket #19) — `srv` is project.hl's server -------// Resident ones (dropped from memory, their user cleared so a still-open socket's session// object cannot be written back signed in) and the files of the ones nobody has open.static dropUserSessions = (&srv, userId) => {sess = srv.sessionsgone = []for (k of sess.map.keys()) {let x = sess.map[k]if (x != null && x.user != null && x.user.id == userId) { gone.push(k) }}for (k of gone) {sess.map[k].user = nullsess.drop(k)sess.map[k] = null}if (sess.dir != null) {for (e of listDir(sess.dir)) {let raw = readFile(e.path)if (raw != null) {let rec = JSON.parse(raw)if (rec != null && rec.user != null && rec.user.id == userId) { remove(e.path) }}}}return null}static setTimeZone = (accountId, tz) => {c = checkTimeZone(tz)if (c.error != null) { return c }a = isId(accountId) ? accountsTable.fetch(accountId) : nullif (a == null) { return { error = 'no such account' } }accountsTable.update(a.id, merged(a, { timeZone = c.timeZone }))return { account = accountRowOf(accountsTable.fetch(a.id)) }}
Branches
- mainmain branch
Latest commits
- 74235815ident mission 009 (4/4): docs (README files map + same-output test, STATUS, LOG), report, tests/letcount.py + realdata-baseline/comparemre
- fe183516ident mission 009 (3/4): let only where reassigned — 293 never-reassigned lets are plain declarations; kept: reassigned, loop bodies, names of a file member, a name declared twice in one function; same outputmre
- d2e7f91bident mission 009 (2/4): one lib file per topic (login, accounts, identities, apps, invites, selector, notify + helpers, util), function routes as thin wrappers in lib/api.hl, project.hl = the map; same outputmre
- 91017164ident mission 009 (1/4): file moves — the root .hl files into lib/ (api.hl → lib/api-helpers.hl), styles.hl → components/styles.hl; imports adjusted, no other changemre
- f8bdcbc2ident: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); all gates greenmre
- ff78726cident: Hybriel master 190aa11d (fc838894 GC correctness, #127, #126 closure scopes); gates all greenmre
- a3a7d21aident: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy); session-writing lambdas take &sessionmre
- 98226b41antcolony#40: mission references point to the moved missionsmre
- ff805b9aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre
- 836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre
- 8bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
- 81b15b7bState of 2026-09-27, before the move to gitoriamre