ident
All repositories: gitoria
27.3 KB
// store.hl — THE SERVER REALM of ident.worldapi.org. hl:mpackdb tables and the rules// that read and write them; statics only (a component imports what it reads by brace).// Concept: CONCEPT.md (the creator's; source of truth). This is piece 1 of 6 (ticket #24):// the account, its identities and its time zone.//// 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 }// identitiesTable pk @id index @account, !shortId { account (account @id), shortId (5 chars, the PUBLIC id, ident#23), identityName, nickname,// firstname, lastname, avatar (data URL of the uploaded picture, ticket #15), created, updated }// every field but `account` optional// otpTable pk @id index email a pending one-time login code:// { email, hash = sha256(email:code), expires, tries } (one per email)// sendsTable pk @id index email one row per code mailed: { email, at } (rate limit)// ipSendsTable pk @id index ip one row per code mailed: { ip (the client's BUCKET), at }// (the per-IP limit, mission 010)//// NO REGISTRATION: the first right code for an address creates the account AND its// default identity. An account ALWAYS has at least one identity: the last one cannot// be deleted. The DEFAULT identity is the account's oldest remaining one (by `created`).// Codes are never stored in clear, only their sha256.import { MPackDB } from 'hl:mpackdb'import { env } from 'hl:proc'import { now } from 'hl:time'import { randomBytes, sha256 } from 'hl:crypto'// IDENT_STORAGE names another table DIRECTORY (the gates run on their own). Relative// paths resolve against the ENTRY SCRIPT's directory — tools must pass an absolute one.static dir = env('IDENT_STORAGE') != null ? env('IDENT_STORAGE') : './storage/mpackdb'static accountsTable = new MPackDB(file = dir + '/accounts.db', primaryKey = '@id', indexes = ['!email'])static identitiesTable = new MPackDB(file = dir + '/identities.db', primaryKey = '@id', indexes = ['@account' '!shortId'])static otpTable = new MPackDB(file = dir + '/otp.db', primaryKey = '@id', indexes = ['email'])static sendsTable = new MPackDB(file = dir + '/sends.db', primaryKey = '@id', indexes = ['email'])static ipSendsTable = new MPackDB(file = dir + '/ipsends.db', primaryKey = '@id', indexes = ['ip'])static envNumber = (name, fallback) => {let v = env(name)if (v == null || v == '') { return fallback }let n = toNumber(v)return n == null ? fallback : n}// THE CLOCKS AND LIMITS (ms). The env names exist for the gate's short-clock server.static otpTtl = envNumber('IDENT_OTP_TTL_MS', 600000) // a login code: 10 minstatic maxTries = 5 // wrong codes before it diesstatic sendWindow = envNumber('IDENT_SEND_WINDOW_MS', 600000) // rate limit window: 10 minstatic sendLimit = envNumber('IDENT_SEND_LIMIT', 3) // codes per email per window// THE PER-IP LIMIT (mission 010): codes mailed per client IP BUCKET (see ipBucket) —// 10 per 10 min and 30 per 24 h, so one client cannot use ident to mail many addressesstatic ipWindow = envNumber('IDENT_IP_WINDOW_MS', 600000) // short window: 10 minstatic ipLimit = envNumber('IDENT_IP_LIMIT', 10) // codes per IP per short windowstatic ipDayWindow = envNumber('IDENT_IP_DAY_WINDOW_MS', 86400000) // long window: 24 hstatic ipDayLimit = envNumber('IDENT_IP_DAY_LIMIT', 30) // codes per IP per long window// 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 daysstatic maxField = 60 // chars per identity fieldstatic fieldNames = ['identityName' 'nickname' 'firstname' 'lastname']// THE AVATAR (ticket #15): an uploaded picture, stored as a small data URL (the page scales it to// 128×128) — not a text field, so it is checked and stored separately from fieldNames/maxFieldstatic maxAvatarUrl = 60000static avatarChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/='// an empty list written to hl:mpackdb comes back as an empty hybrid (`.length` null)static countOf = (list) => {if (list == null) { return 0 }let n = list.lengthreturn n == null ? 0 : n}static first = (list) => { return countOf(list) > 0 ? list[0] : null }// A PUBLIC ID is an mpackdb UUID: a non-empty string (a number is never one — an old// numeric id from before mission 009 finds nothing)static isId = (v) => { return v != null && hlTypeName(v) == 'String' && v.length > 0 && v.length <= 64 }// OLDEST FIRST: by the stored `created`, a tie by the id (never by key order)static olderThan = (a, b) => {let ca = a.created == null ? 0 : a.createdlet cb = b.created == null ? 0 : b.createdif (ca != cb) { return ca < cb }return a.id < b.id // strings order by code point (hybriel#2)}// a found list, oldest first (insertion sort by hand: no list sort(), hybriel #6)static oldestFirst = (list) => {let out = []if (countOf(list) == 0) { return out }for (x of list) { out.push(x) }let i = 1while (i < out.length) {let cur = out[i]let j = i - 1while (j >= 0 && olderThan(cur, out[j])) {out[j + 1] = out[j]j = j - 1}out[j + 1] = curi = i + 1}return out}// every record is written as a whole (hl:mpackdb's update replaces it)static merged = (rec, changes) => {let out = {}for (k of rec.keys()) { out[k] = rec[k] }for (k of changes.keys()) { out[k] = changes[k] }return out}// ---- validation -----------------------------------------------------------------------static normEmail = (email) => { return email == null ? '' : ('' + email).trim().toLowerCase() }static validEmail = (email) => {if (email == null || email.length < 3 || email.length > 200) { return false }let at = email.indexOf('@')if (at < 1 || at != email.lastIndexOf('@') || at == email.length - 1) { return false }if (!email.slice(at + 1, email.length).includes('.')) { return false }// a whitelist: the language has no escapes to name a CR or a tab withlet ok = 'abcdefghijklmnopqrstuvwxyz0123456789.-_+@'let i = 0while (i < email.length) {if (!ok.includes(email[i])) { return false }i = i + 1}return true}// no control characters (a field is one line of text)static hasControl = (s) => {let i = 0while (i < s.length) {let c = s.charCodeAt(i)if (c < 32 || c == 127) { return true }i = i + 1}return false}// A TIME ZONE is an IANA name as the browser reports it (`Europe/Vienna`, `UTC`,// `America/Argentina/Buenos_Aires`, `Etc/GMT+5`). hl:time knows no zones (hybriel #11),// so the server checks the SHAPE only; the page checks the name against the browser's// own list (Intl.supportedValuesOf) before it sends it.static zoneChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/_+-'static checkTimeZone = (tz) => {if (tz == null || hlTypeName(tz) != 'String') { return { error = 'the time zone must be a string' } }let t = tz.trim()if (t == '') { return { error = 'the time zone is empty' } }if (t.length > 64) { return { error = 'the time zone name is too long' } }let i = 0while (i < t.length) {if (!zoneChars.includes(t[i])) { return { error = 'that is not a time zone name (like Europe/Vienna)' } }i = i + 1}if (t[0] == '/' || t[t.length - 1] == '/' || t.includes('//')) { return { error = 'that is not a time zone name (like Europe/Vienna)' } }return { timeZone = t }}// AN AVATAR: empty (none) or the uploaded picture as a data URL — data:image/(png|jpeg|webp);base64,// with the file's own magic bytes at the start of the base64 text (the page scales the upload to// 128×128 first, avatar.js), ≤ maxAvatarUrl characters, base64 characters onlystatic checkAvatarUrl = (v) => {if (v == null || hlTypeName(v) != 'String') { return { error = 'field avatar must be a string' } }let t = v.trim()if (t == '') { return { avatar = '' } }if (t.length > maxAvatarUrl) { return { error = 'field avatar is longer than ' + maxAvatarUrl + ' characters' } }let kind = ''let start = 0if (t.startsWith('data:image/png;base64,')) { kind = 'iVBORw0KGgo' start = 22 }else if (t.startsWith('data:image/jpeg;base64,')) { kind = '/9j/' start = 23 }else if (t.startsWith('data:image/webp;base64,')) { kind = 'UklGR' start = 23 }if (kind == '') { return { error = 'field avatar must be an uploaded PNG, JPEG or WebP picture' } }let data = t.slice(start, t.length)if (!data.startsWith(kind)) { return { error = 'field avatar is not a valid picture' } }let i = 0while (i < data.length) {if (!avatarChars.includes(data[i])) { return { error = 'field avatar is not valid base64' } }i = i + 1}return { avatar = t }}// the avatar to show: only an uploaded picture (a data URL). Before the upload existed an avatar// was an http(s) URL; those are no longer shown (never fetched from a third-party address).static shownAvatar = (r) => { return str(r.avatar).startsWith('data:image/') ? str(r.avatar) : '' }// THE IDENTITY FIELDS, STRICT: `fields` is an object whose keys are among fieldNames plus// `avatar`. A name field is a string (trimmed, at most 60 chars, no control characters);// `avatar` is checked by checkAvatarUrl. Absent keys are absent from the answer.// Answers { error, field } or { fields }.static checkFields = (fields) => {if (fields == null || hlTypeName(fields) != 'Hybrid' || fields.length != null) { return { error = 'the identity fields must be an object' field = '' } }let out = {}for (k of fields.keys()) {if (k == 'avatar') {let c = checkAvatarUrl(fields[k])if (c.error != null) { return { error = c.error field = 'avatar' } }out.avatar = c.avatar} else if (!fieldNames.includes(k)) { return { error = 'unknown field: ' + k field = k } }else {let v = fields[k]if (v == null || hlTypeName(v) != 'String') { return { error = 'field ' + k + ' must be a string' field = k } }let t = v.trim()if (t.length > maxField) { return { error = 'field ' + k + ' is longer than ' + maxField + ' characters' field = k } }if (hasControl(t)) { return { error = 'field ' + k + ' contains a control character' field = k } }out[k] = t}}return { fields = out }}// ---- codes --------------------------------------------------------------------------// six digits from the kernel CSPRNG (randomBytes answers hex)static newOtp = () => {let hex = randomBytes(8)let n = 0let i = 0while (i < 12) {n = (n * 16 + '0123456789abcdef'.indexOf(hex[i])) % 1000000i = i + 1}let s = '' + nwhile (s.length < 6) { s = '0' + s }return s}static otpHash = (email, code) => { return sha256(email + ':' + code) }// ---- accounts -------------------------------------------------------------------------// 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 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).static accountEpoch = (a) => { return a.sessionEpoch == null ? 0 : a.sessionEpoch }static beginSession = (session, accountId) => {if (session == null) { return false }let 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 }let 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`, it owns the// session store); the epoch stays as the second net for any session the sweep did not see.static sessionHooks = { dropUser = null }static signOutEverywhere = (accountId) => {let 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 }}static setTimeZone = (accountId, tz) => {let c = checkTimeZone(tz)if (c.error != null) { return c }let 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)) }}// ---- THE SHORT ID (ident#23) ------------------------------------------------------------// ONE PUBLIC ID per identity, kept forever, the same in every app: 5 characters (e.g. a68sz)// from an alphabet without look-alikes — digits 2-9 and the letters minus i, l, o (no 0/O,// 1/l/I): 31 characters, 28.6 million ids. Case does not matter (stored lowercase, looked up// lowercased). Unique: checked against the `!shortId` index when it is made. Random (not// counted), so it says nothing about how many identities exist.static shortAlphabet = '23456789abcdefghjkmnpqrstuvwxyz'static shortLength = 5static newShortId = () => {let out = ''while (out.length < shortLength) {let hex = randomBytes(16)let i = 0while (i < 32 && out.length < shortLength) {let b = '0123456789abcdef'.indexOf(hex[i]) * 16 + '0123456789abcdef'.indexOf(hex[i + 1])// 248 = 8 * 31: bytes above it are dropped so every character is equally likelyif (b < 248) { out = out + shortAlphabet[b % 31] }i = i + 2}}return out}// a fresh short id nobody hasstatic freshShortId = () => {let id = newShortId()while (countOf(identitiesTable.find('shortId', id)) > 0) { id = newShortId() }return id}// what a person typed → the lowercase form, or null when it cannot be a short idstatic normShortId = (v) => {if (v == null || hlTypeName(v) != 'String') { return null }let t = v.trim().toLowerCase()if (t.length != shortLength) { return null }let i = 0while (i < t.length) {if (shortAlphabet.indexOf(t[i]) < 0) { return null }i = i + 1}return t}static identityByShortId = (v) => {let t = normShortId(v)return t == null ? null : first(identitiesTable.find('shortId', t))}// every identity made before ident#23 gets its short id now, once (at load)static backfillShortIds = () => {let n = 0let rows = identitiesTable.find(null, null)if (countOf(rows) > 0) {for (r of rows) {if (r.shortId == null || r.shortId == '') {identitiesTable.update(r.id, merged(r, { shortId = freshShortId() }))n = n + 1}}}return n}static shortIdsBackfilled = backfillShortIds()// ---- identities -----------------------------------------------------------------------static str = (v) => { return v == null ? '' : v }// the account's identities, oldest first (stored `created`); the first is the DEFAULT onestatic identityRecords = (accountId) => {if (!isId(accountId)) { return [] }return oldestFirst(identitiesTable.find('account', accountId))}// WHAT A LIST SHOWS: the identity name; without one, nickname, then first + last name,// then "Identity <n>" (n = its place in the list)static labelOf = (r, n) => {if (str(r.identityName) != '') { return r.identityName }if (str(r.nickname) != '') { return r.nickname }let full = (str(r.firstname) + ' ' + str(r.lastname)).trim()if (full != '') { return full }return 'Identity ' + n}static identityRows = (accountId) => {let recs = identityRecords(accountId)let out = []let n = 1for (r of recs) {let full = (str(r.firstname) + ' ' + str(r.lastname)).trim()let parts = []if (str(r.nickname) != '') { parts.push('“' + r.nickname + '”') }if (full != '') { parts.push(full) }out.push({id = r.idshortId = str(r.shortId)label = labelOf(r, n)identityName = str(r.identityName)nickname = str(r.nickname)firstname = str(r.firstname)lastname = str(r.lastname)avatar = shownAvatar(r)hasAvatar = shownAvatar(r) != ''details = parts.length > 0 ? parts.join(' · ') : 'no names'isDefault = n == 1canDelete = recs.length > 1})n = n + 1}return out}// the identity `id` if it belongs to the account, else nullstatic ownIdentity = (accountId, id) => {if (!isId(id)) { return null }let r = identitiesTable.fetch(id)if (r == null || r.account != accountId) { return null }return r}// answers { error, field } or { identity (public id) }static createIdentity = (accountId, fields) => {let c = checkFields(fields)if (c.error != null) { return c }let rec = { account = accountId shortId = freshShortId() identityName = '' nickname = '' firstname = '' lastname = '' avatar = '' created = now() updated = now() }for (k of c.fields.keys()) { rec[k] = c.fields[k] }let id = identitiesTable.put(rec)return { identity = id }}// only the fields named change; answers { error, field } or { identity }static updateIdentity = (accountId, id, fields) => {let r = ownIdentity(accountId, id)if (r == null) { return { error = 'no such identity' field = 'id' } }let c = checkFields(fields)if (c.error != null) { return c }let changes = { updated = now() }for (k of c.fields.keys()) { changes[k] = c.fields[k] }identitiesTable.update(r.id, merged(r, changes))return { identity = id }}// THE LAST IDENTITY STAYS: answers { error } or { deleted }static deleteIdentity = (accountId, id) => {let r = ownIdentity(accountId, id)if (r == null) { return { error = 'no such identity' } }if (identityRecords(accountId).length <= 1) { return { error = 'this is your only identity — an account always keeps one' } }identitiesTable.delete(r.id)return { deleted = id }}// ---- the client's IP bucket (mission 010) -------------------------------------------// `ip` is the X-Client-IP header nginx sets on Byrodin (CF-Connecting-IP when the request// really came through Cloudflare, else the TCP peer; nginx OVERWRITES what a client sent),// or null when there is none (dev without nginx: hl:web's req.remoteAddress would be nginx's; hl:http1 did not expose the// connection's peer address). The bucket:// no header / empty → 'direct' ONE SHARED BUCKET for every header-less request// IPv4 (also ::ffff:a.b.c.d) → the address// IPv6 → its /64 prefix ('2a01:4f9:3080:1126::/64'): one client// usually owns a whole /64, so per-address would not limit it// anything else → the text itself (lowercased, cut to 64 chars)static hexDigits = '0123456789abcdef'static stripZeros = (g) => {let t = gwhile (t.length > 1 && t[0] == '0') { t = t.slice(1, t.length) }return t}static ipBucket = (ip) => {if (ip == null || hlTypeName(ip) != 'String') { return 'direct' }let t = ip.trim().toLowerCase()if (t == '') { return 'direct' }if (t.length > 64) { t = t.slice(0, 64) }if (!t.includes(':')) { return t }// an IPv4-mapped IPv6 address counts as its IPv4 addressif (t.includes('.')) { return t.slice(t.lastIndexOf(':') + 1, t.length) }let head = tlet tail = ''let gap = t.indexOf('::')if (gap >= 0) {head = t.slice(0, gap)tail = t.slice(gap + 2, t.length)}let groups = head == '' ? [] : head.split(':')let rest = tail == '' ? [] : tail.split(':')if (gap >= 0) {let fill = 8 - groups.length - rest.lengthwhile (fill > 0) {groups.push('0')fill = fill - 1}}for (g of rest) { groups.push(g) }if (groups.length != 8) { return t }let out = []let i = 0while (i < 4) {let g = groups[i]if (g == '' || g.length > 4) { return t }let j = 0while (j < g.length) {if (!hexDigits.includes(g[j])) { return t }j = j + 1}out.push(stripZeros(g))i = i + 1}return out.join(':') + '::/64'}// how many codes went to this bucket in the short and in the long window (and the rows// older than the long window are removed on the way)static ipCounts = (bucket, t0) => {let inWindow = 0let inDay = 0let rows = ipSendsTable.find('ip', bucket)if (countOf(rows) > 0) {for (s of rows) {if (s.at > t0 - ipDayWindow) {inDay = inDay + 1if (s.at > t0 - ipWindow) { inWindow = inWindow + 1 }} else {ipSendsTable.delete(s.id)}}}return { inWindow = inWindow inDay = inDay }}// ---- step 1: ask for a code ---------------------------------------------------------// answers { error, limited } or { email, code } — the caller mails the code. The answer// does not say whether the address has an account (the first login creates it).// `ip` is the client's IP (the X-Client-IP header, or null — see ipBucket). `limited` is// true when a limit refused it (the route answers 429 then).static startLogin = (email, ip) => {let e = normEmail(email)if (!validEmail(e)) { return { error = 'that is not an email address' } }let t0 = now()// THE PER-IP LIMIT first: one client, many addresseslet bucket = ipBucket(ip)let ipc = ipCounts(bucket, t0)if (ipc.inWindow >= ipLimit || ipc.inDay >= ipDayLimit) {return { error = 'too many codes were requested from your network — wait a while and try again' limited = true }}let recent = 0let sends = sendsTable.find('email', e)if (countOf(sends) > 0) {for (s of sends) {if (s.at > t0 - sendWindow) { recent = recent + 1 } else { sendsTable.delete(s.id) }}}if (recent >= sendLimit) {return { error = 'too many codes were sent to this address — wait a few minutes and try again' limited = true }}let olds = otpTable.find('email', e)if (countOf(olds) > 0) { for (old of olds) { otpTable.delete(old.id) } }let code = newOtp()otpTable.put({ email = e hash = otpHash(e, code) expires = t0 + otpTtl tries = 0 })sendsTable.put({ email = e at = t0 })ipSendsTable.put({ ip = bucket at = t0 })return { email = e code = code }}// ---- step 2: check it — and on the FIRST login create the account -------------------// answers { error } or { account, created }. `timeZone` is the browser's (first use);// a missing or malformed one is stored as UTC, it can be changed in ident.static checkCode = (email, code, timeZone) => {let e = normEmail(email)let c = code == null ? '' : ('' + code).trim()let p = first(otpTable.find('email', e))if (p == null) { return { error = 'no code is waiting for this address — ask for a new one' } }if (p.expires < now()) {otpTable.delete(p.id)return { error = 'the code expired — ask for a new one' }}if (otpHash(e, c) != p.hash) {if (p.tries + 1 >= maxTries) {otpTable.delete(p.id)return { error = 'too many wrong codes — ask for a new one' }}otpTable.update(p.id, merged(p, { tries = p.tries + 1 }))return { error = 'wrong code (' + (maxTries - p.tries - 1) + ' tries left)' }}otpTable.delete(p.id)let known = accountByEmail(e)if (known != null) { return { account = known created = false } }let tz = checkTimeZone(timeZone)let id = accountsTable.put({ email = e timeZone = tz.error == null ? tz.timeZone : 'UTC' created = now() })// THE DEFAULT IDENTITY, from the beginning; its names are optional (asked next)identitiesTable.put({ account = id shortId = freshShortId() identityName = 'Default' nickname = '' firstname = '' lastname = '' avatar = '' created = now() updated = now() })return { account = accountRowOf(accountsTable.fetch(id)) created = true }}// ---- THE PENDING SIGN-IN (ticket ident#20, mission 032) ------------------------------// After "Send me a code" the browser's SESSION remembers the address, so a reload (a phone// reloading the tab while the user reads the mail, a second tab, a socket re-seed) still// shows the code step. It lives in `session.data.pendingEmail` (the app's hybrid on the// Session — an undeclared member of the instance would not read back, see session.hl).// It is ONLY honoured while a code for that address is really waiting: in otpTable, not// expired, not used, not killed by too many wrong tries — so it never shows a code step// for an address no code was sent to. It holds no secret (the code is only in the mail).// Cleared on: a successful sign-in (verifyCode), "Other address" (dropPending), and// expiry / a dead code (pendingOf drops it the next time it reads it).static codeWaiting = (email) => {let e = normEmail(email)if (e == '') { return false }let p = first(otpTable.find('email', e))return p != null && p.expires >= now() && p.tries < maxTries}// the face rememberPending (home.hl): { email } or { error }static recordPending = (session, email) => {if (session == null) { return { error = 'no session — reload the page' } }if (email == null || hlTypeName(email) != 'String') { return { error = 'field email must be a string' } }let e = normEmail(email)if (!codeWaiting(e)) { return { error = 'no code is waiting for this address' } }session.data.pendingEmail = ereturn { email = e }}// the address whose code step this session shows, or null (and a stale one is dropped)static pendingOf = (session) => {if (session == null || session.data == null) { return null }let e = session.data.pendingEmailif (e == null || e == '') { return null }if (!codeWaiting(e)) {session.data.pendingEmail = nullreturn null}return e}static dropPending = (session) => {if (session != null && session.data != null) { session.data.pendingEmail = null }return true}
Branches
- mainmain branch
Latest commits
- 81b15b7bState of 2026-09-27, before the move to gitoriamre