gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit51a7bcdf51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre51a7bcdf/store.hl

27.3 KB

  1. // store.hl — THE SERVER REALM of ident.worldapi.org. hl:mpackdb tables and the rules
  2. // that read and write them; statics only (a component imports what it reads by brace).
  3. // Concept: CONCEPT.md (the creator's; source of truth). This is piece 1 of 6 (ticket #24):
  4. // the account, its identities and its time zone.
  5. //
  6. // CREATOR'S CONVENTIONS (2026-09-24, ticket ident #10 / old #39, mission 009): every
  7. // primary key is the mpackdb UUID (`@id`, a 12-char string like '0mufbkwhlpjq'), never a
  8. // `*id` counter; the files live in storage/mpackdb/<table>.*. The public ids ARE these
  9. // UUIDs (accounts, identities, apps). Order never comes from key order: "oldest first"
  10. // sorts by the stored `created` time (then the id, only to break a tie).
  11. // Rows migrated from the old `*id` store (tools/migrate-009.hl) carry `oldId` = the old
  12. // public id (old mpackdb id + 1) — only the migration reads it.
  13. //
  14. // accountsTable pk @id index !email { email (lowercased), timeZone, created }
  15. // identitiesTable pk @id index @account, !shortId { account (account @id), shortId (5 chars, the PUBLIC id, ident#23), identityName, nickname,
  16. // firstname, lastname, avatar (data URL of the uploaded picture, ticket #15), created, updated }
  17. // every field but `account` optional
  18. // otpTable pk @id index email a pending one-time login code:
  19. // { email, hash = sha256(email:code), expires, tries } (one per email)
  20. // sendsTable pk @id index email one row per code mailed: { email, at } (rate limit)
  21. // ipSendsTable pk @id index ip one row per code mailed: { ip (the client's BUCKET), at }
  22. // (the per-IP limit, mission 010)
  23. //
  24. // NO REGISTRATION: the first right code for an address creates the account AND its
  25. // default identity. An account ALWAYS has at least one identity: the last one cannot
  26. // be deleted. The DEFAULT identity is the account's oldest remaining one (by `created`).
  27. // Codes are never stored in clear, only their sha256.
  28. import { MPackDB } from 'hl:mpackdb'
  29. import { env } from 'hl:proc'
  30. import { now } from 'hl:time'
  31. import { randomBytes, sha256 } from 'hl:crypto'
  32. // IDENT_STORAGE names another table DIRECTORY (the gates run on their own). Relative
  33. // paths resolve against the ENTRY SCRIPT's directory — tools must pass an absolute one.
  34. static dir = env('IDENT_STORAGE') != null ? env('IDENT_STORAGE') : './storage/mpackdb'
  35. static accountsTable = new MPackDB(file = dir + '/accounts.db', primaryKey = '@id', indexes = ['!email'])
  36. static identitiesTable = new MPackDB(file = dir + '/identities.db', primaryKey = '@id', indexes = ['@account' '!shortId'])
  37. static otpTable = new MPackDB(file = dir + '/otp.db', primaryKey = '@id', indexes = ['email'])
  38. static sendsTable = new MPackDB(file = dir + '/sends.db', primaryKey = '@id', indexes = ['email'])
  39. static ipSendsTable = new MPackDB(file = dir + '/ipsends.db', primaryKey = '@id', indexes = ['ip'])
  40. static envNumber = (name, fallback) => {
  41. let v = env(name)
  42. if (v == null || v == '') { return fallback }
  43. let n = toNumber(v)
  44. return n == null ? fallback : n
  45. }
  46. // THE CLOCKS AND LIMITS (ms). The env names exist for the gate's short-clock server.
  47. static otpTtl = envNumber('IDENT_OTP_TTL_MS', 600000) // a login code: 10 min
  48. static maxTries = 5 // wrong codes before it dies
  49. static sendWindow = envNumber('IDENT_SEND_WINDOW_MS', 600000) // rate limit window: 10 min
  50. static sendLimit = envNumber('IDENT_SEND_LIMIT', 3) // codes per email per window
  51. // THE PER-IP LIMIT (mission 010): codes mailed per client IP BUCKET (see ipBucket) —
  52. // 10 per 10 min and 30 per 24 h, so one client cannot use ident to mail many addresses
  53. static ipWindow = envNumber('IDENT_IP_WINDOW_MS', 600000) // short window: 10 min
  54. static ipLimit = envNumber('IDENT_IP_LIMIT', 10) // codes per IP per short window
  55. static ipDayWindow = envNumber('IDENT_IP_DAY_WINDOW_MS', 86400000) // long window: 24 h
  56. static ipDayLimit = envNumber('IDENT_IP_DAY_LIMIT', 30) // codes per IP per long window
  57. // A SIGNED-IN SESSION'S OWN EXPIRY (ticket #2, hardening): independent of the hl:web
  58. // session file's rolling idle/maxAge, so a browser left open cannot stay signed in to
  59. // ident forever — the account's OTP must be proven again after this many ms.
  60. static sessionUserTtl = envNumber('IDENT_SESSION_TTL_MS', 1209600000) // 14 days
  61. static maxField = 60 // chars per identity field
  62. static fieldNames = ['identityName' 'nickname' 'firstname' 'lastname']
  63. // THE AVATAR (ticket #15): an uploaded picture, stored as a small data URL (the page scales it to
  64. // 128×128) — not a text field, so it is checked and stored separately from fieldNames/maxField
  65. static maxAvatarUrl = 60000
  66. static avatarChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/='
  67. // an empty list written to hl:mpackdb comes back as an empty hybrid (`.length` null)
  68. static countOf = (list) => {
  69. if (list == null) { return 0 }
  70. let n = list.length
  71. return n == null ? 0 : n
  72. }
  73. static first = (list) => { return countOf(list) > 0 ? list[0] : null }
  74. // A PUBLIC ID is an mpackdb UUID: a non-empty string (a number is never one — an old
  75. // numeric id from before mission 009 finds nothing)
  76. static isId = (v) => { return v != null && hlTypeName(v) == 'String' && v.length > 0 && v.length <= 64 }
  77. // OLDEST FIRST: by the stored `created`, a tie by the id (never by key order)
  78. static olderThan = (a, b) => {
  79. let ca = a.created == null ? 0 : a.created
  80. let cb = b.created == null ? 0 : b.created
  81. if (ca != cb) { return ca < cb }
  82. return a.id < b.id // strings order by code point (hybriel#2)
  83. }
  84. // a found list, oldest first (insertion sort by hand: no list sort(), hybriel #6)
  85. static oldestFirst = (list) => {
  86. let out = []
  87. if (countOf(list) == 0) { return out }
  88. for (x of list) { out.push(x) }
  89. let i = 1
  90. while (i < out.length) {
  91. let cur = out[i]
  92. let j = i - 1
  93. while (j >= 0 && olderThan(cur, out[j])) {
  94. out[j + 1] = out[j]
  95. j = j - 1
  96. }
  97. out[j + 1] = cur
  98. i = i + 1
  99. }
  100. return out
  101. }
  102. // every record is written as a whole (hl:mpackdb's update replaces it)
  103. static merged = (rec, changes) => {
  104. let out = {}
  105. for (k of rec.keys()) { out[k] = rec[k] }
  106. for (k of changes.keys()) { out[k] = changes[k] }
  107. return out
  108. }
  109. // ---- validation -----------------------------------------------------------------------
  110. static normEmail = (email) => { return email == null ? '' : ('' + email).trim().toLowerCase() }
  111. static validEmail = (email) => {
  112. if (email == null || email.length < 3 || email.length > 200) { return false }
  113. let at = email.indexOf('@')
  114. if (at < 1 || at != email.lastIndexOf('@') || at == email.length - 1) { return false }
  115. if (!email.slice(at + 1, email.length).includes('.')) { return false }
  116. // a whitelist: the language has no escapes to name a CR or a tab with
  117. let ok = 'abcdefghijklmnopqrstuvwxyz0123456789.-_+@'
  118. let i = 0
  119. while (i < email.length) {
  120. if (!ok.includes(email[i])) { return false }
  121. i = i + 1
  122. }
  123. return true
  124. }
  125. // no control characters (a field is one line of text)
  126. static hasControl = (s) => {
  127. let i = 0
  128. while (i < s.length) {
  129. let c = s.charCodeAt(i)
  130. if (c < 32 || c == 127) { return true }
  131. i = i + 1
  132. }
  133. return false
  134. }
  135. // A TIME ZONE is an IANA name as the browser reports it (`Europe/Vienna`, `UTC`,
  136. // `America/Argentina/Buenos_Aires`, `Etc/GMT+5`). hl:time knows no zones (hybriel #11),
  137. // so the server checks the SHAPE only; the page checks the name against the browser's
  138. // own list (Intl.supportedValuesOf) before it sends it.
  139. static zoneChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/_+-'
  140. static checkTimeZone = (tz) => {
  141. if (tz == null || hlTypeName(tz) != 'String') { return { error = 'the time zone must be a string' } }
  142. let t = tz.trim()
  143. if (t == '') { return { error = 'the time zone is empty' } }
  144. if (t.length > 64) { return { error = 'the time zone name is too long' } }
  145. let i = 0
  146. while (i < t.length) {
  147. if (!zoneChars.includes(t[i])) { return { error = 'that is not a time zone name (like Europe/Vienna)' } }
  148. i = i + 1
  149. }
  150. if (t[0] == '/' || t[t.length - 1] == '/' || t.includes('//')) { return { error = 'that is not a time zone name (like Europe/Vienna)' } }
  151. return { timeZone = t }
  152. }
  153. // AN AVATAR: empty (none) or the uploaded picture as a data URL — data:image/(png|jpeg|webp);base64,
  154. // with the file's own magic bytes at the start of the base64 text (the page scales the upload to
  155. // 128×128 first, avatar.js), ≤ maxAvatarUrl characters, base64 characters only
  156. static checkAvatarUrl = (v) => {
  157. if (v == null || hlTypeName(v) != 'String') { return { error = 'field avatar must be a string' } }
  158. let t = v.trim()
  159. if (t == '') { return { avatar = '' } }
  160. if (t.length > maxAvatarUrl) { return { error = 'field avatar is longer than ' + maxAvatarUrl + ' characters' } }
  161. let kind = ''
  162. let start = 0
  163. if (t.startsWith('data:image/png;base64,')) { kind = 'iVBORw0KGgo' start = 22 }
  164. else if (t.startsWith('data:image/jpeg;base64,')) { kind = '/9j/' start = 23 }
  165. else if (t.startsWith('data:image/webp;base64,')) { kind = 'UklGR' start = 23 }
  166. if (kind == '') { return { error = 'field avatar must be an uploaded PNG, JPEG or WebP picture' } }
  167. let data = t.slice(start, t.length)
  168. if (!data.startsWith(kind)) { return { error = 'field avatar is not a valid picture' } }
  169. let i = 0
  170. while (i < data.length) {
  171. if (!avatarChars.includes(data[i])) { return { error = 'field avatar is not valid base64' } }
  172. i = i + 1
  173. }
  174. return { avatar = t }
  175. }
  176. // the avatar to show: only an uploaded picture (a data URL). Before the upload existed an avatar
  177. // was an http(s) URL; those are no longer shown (never fetched from a third-party address).
  178. static shownAvatar = (r) => { return str(r.avatar).startsWith('data:image/') ? str(r.avatar) : '' }
  179. // THE IDENTITY FIELDS, STRICT: `fields` is an object whose keys are among fieldNames plus
  180. // `avatar`. A name field is a string (trimmed, at most 60 chars, no control characters);
  181. // `avatar` is checked by checkAvatarUrl. Absent keys are absent from the answer.
  182. // Answers { error, field } or { fields }.
  183. static checkFields = (fields) => {
  184. if (fields == null || hlTypeName(fields) != 'Hybrid' || fields.length != null) { return { error = 'the identity fields must be an object' field = '' } }
  185. let out = {}
  186. for (k of fields.keys()) {
  187. if (k == 'avatar') {
  188. let c = checkAvatarUrl(fields[k])
  189. if (c.error != null) { return { error = c.error field = 'avatar' } }
  190. out.avatar = c.avatar
  191. } else if (!fieldNames.includes(k)) { return { error = 'unknown field: ' + k field = k } }
  192. else {
  193. let v = fields[k]
  194. if (v == null || hlTypeName(v) != 'String') { return { error = 'field ' + k + ' must be a string' field = k } }
  195. let t = v.trim()
  196. if (t.length > maxField) { return { error = 'field ' + k + ' is longer than ' + maxField + ' characters' field = k } }
  197. if (hasControl(t)) { return { error = 'field ' + k + ' contains a control character' field = k } }
  198. out[k] = t
  199. }
  200. }
  201. return { fields = out }
  202. }
  203. // ---- codes --------------------------------------------------------------------------
  204. // six digits from the kernel CSPRNG (randomBytes answers hex)
  205. static newOtp = () => {
  206. let hex = randomBytes(8)
  207. let n = 0
  208. let i = 0
  209. while (i < 12) {
  210. n = (n * 16 + '0123456789abcdef'.indexOf(hex[i])) % 1000000
  211. i = i + 1
  212. }
  213. let s = '' + n
  214. while (s.length < 6) { s = '0' + s }
  215. return s
  216. }
  217. static otpHash = (email, code) => { return sha256(email + ':' + code) }
  218. // ---- accounts -------------------------------------------------------------------------
  219. // PUBLIC ACCOUNT / IDENTITY IDS are the records' UUIDs
  220. static accountRowOf = (a) => { return a == null ? null : { id = a.id email = a.email timeZone = a.timeZone } }
  221. static accountById = (id) => { return isId(id) ? accountRowOf(accountsTable.fetch(id)) : null }
  222. static accountByEmail = (email) => { return accountRowOf(first(accountsTable.find('email', normEmail(email)))) }
  223. // the account a session carries, re-read from the table (null when signed out or gone).
  224. // ONLY A REAL SESSION COUNTS: a face's trailing `session` argument is positional, so a
  225. // hand-made emit with one argument too many would hand the face its own object in the
  226. // session's place (see README "Lessons"); the framework's session is a class instance (hlTypeName 'Instance'), JSON never is.
  227. // SIGNS A SESSION IN (ticket #2): stamps `since` (this session's own expiry, sessionUserTtl)
  228. // and `epoch` (the account's current sessionEpoch — "sign out everywhere" bumps it, which
  229. // makes every session that still carries the old epoch read as signed out, see below).
  230. static accountEpoch = (a) => { return a.sessionEpoch == null ? 0 : a.sessionEpoch }
  231. static beginSession = (session, accountId) => {
  232. if (session == null) { return false }
  233. let a = isId(accountId) ? accountsTable.fetch(accountId) : null
  234. if (a == null) { return false }
  235. session.user = { id = accountId since = now() epoch = accountEpoch(a) }
  236. return true
  237. }
  238. static accountOfSession = (session) => {
  239. if (session == null || session.user == null) { return null }
  240. let a = accountsTable.fetch(session.user.id)
  241. if (a == null) { session.user = null return null }
  242. // AN OLDER SESSION (before ticket #2, incl. one carried across the mission-009
  243. // migration): it has no `since`/`epoch` yet. Adopt them now instead of forcing
  244. // everyone signed out the moment this ships — its own-expiry clock starts here.
  245. if (session.user.since == null || session.user.epoch == null) {
  246. session.user = { id = session.user.id since = now() epoch = accountEpoch(a) }
  247. return accountRowOf(a)
  248. }
  249. if (now() - session.user.since > sessionUserTtl || session.user.epoch != accountEpoch(a)) {
  250. session.user = null
  251. return null
  252. }
  253. return accountRowOf(a)
  254. }
  255. // "SIGN OUT EVERYWHERE": bumps the account's sessionEpoch, so every session that carries
  256. // this account (this one included — the caller's UI resets the same as a normal sign out)
  257. // stops working at its next check, on every device, without touching any other session file.
  258. // The session files themselves are DELETED too (project.hl hands in `dropUser`, it owns the
  259. // session store); the epoch stays as the second net for any session the sweep did not see.
  260. static sessionHooks = { dropUser = null }
  261. static signOutEverywhere = (accountId) => {
  262. let a = isId(accountId) ? accountsTable.fetch(accountId) : null
  263. if (a == null) { return { error = 'no such account' } }
  264. if (sessionHooks.dropUser != null) { sessionHooks.dropUser(accountId) }
  265. accountsTable.update(a.id, merged(a, { sessionEpoch = accountEpoch(a) + 1 }))
  266. return { ok = true }
  267. }
  268. static setTimeZone = (accountId, tz) => {
  269. let c = checkTimeZone(tz)
  270. if (c.error != null) { return c }
  271. let a = isId(accountId) ? accountsTable.fetch(accountId) : null
  272. if (a == null) { return { error = 'no such account' } }
  273. accountsTable.update(a.id, merged(a, { timeZone = c.timeZone }))
  274. return { account = accountRowOf(accountsTable.fetch(a.id)) }
  275. }
  276. // ---- THE SHORT ID (ident#23) ------------------------------------------------------------
  277. // ONE PUBLIC ID per identity, kept forever, the same in every app: 5 characters (e.g. a68sz)
  278. // from an alphabet without look-alikes — digits 2-9 and the letters minus i, l, o (no 0/O,
  279. // 1/l/I): 31 characters, 28.6 million ids. Case does not matter (stored lowercase, looked up
  280. // lowercased). Unique: checked against the `!shortId` index when it is made. Random (not
  281. // counted), so it says nothing about how many identities exist.
  282. static shortAlphabet = '23456789abcdefghjkmnpqrstuvwxyz'
  283. static shortLength = 5
  284. static newShortId = () => {
  285. let out = ''
  286. while (out.length < shortLength) {
  287. let hex = randomBytes(16)
  288. let i = 0
  289. while (i < 32 && out.length < shortLength) {
  290. let b = '0123456789abcdef'.indexOf(hex[i]) * 16 + '0123456789abcdef'.indexOf(hex[i + 1])
  291. // 248 = 8 * 31: bytes above it are dropped so every character is equally likely
  292. if (b < 248) { out = out + shortAlphabet[b % 31] }
  293. i = i + 2
  294. }
  295. }
  296. return out
  297. }
  298. // a fresh short id nobody has
  299. static freshShortId = () => {
  300. let id = newShortId()
  301. while (countOf(identitiesTable.find('shortId', id)) > 0) { id = newShortId() }
  302. return id
  303. }
  304. // what a person typed → the lowercase form, or null when it cannot be a short id
  305. static normShortId = (v) => {
  306. if (v == null || hlTypeName(v) != 'String') { return null }
  307. let t = v.trim().toLowerCase()
  308. if (t.length != shortLength) { return null }
  309. let i = 0
  310. while (i < t.length) {
  311. if (shortAlphabet.indexOf(t[i]) < 0) { return null }
  312. i = i + 1
  313. }
  314. return t
  315. }
  316. static identityByShortId = (v) => {
  317. let t = normShortId(v)
  318. return t == null ? null : first(identitiesTable.find('shortId', t))
  319. }
  320. // every identity made before ident#23 gets its short id now, once (at load)
  321. static backfillShortIds = () => {
  322. let n = 0
  323. let rows = identitiesTable.find(null, null)
  324. if (countOf(rows) > 0) {
  325. for (r of rows) {
  326. if (r.shortId == null || r.shortId == '') {
  327. identitiesTable.update(r.id, merged(r, { shortId = freshShortId() }))
  328. n = n + 1
  329. }
  330. }
  331. }
  332. return n
  333. }
  334. static shortIdsBackfilled = backfillShortIds()
  335. // ---- identities -----------------------------------------------------------------------
  336. static str = (v) => { return v == null ? '' : v }
  337. // the account's identities, oldest first (stored `created`); the first is the DEFAULT one
  338. static identityRecords = (accountId) => {
  339. if (!isId(accountId)) { return [] }
  340. return oldestFirst(identitiesTable.find('account', accountId))
  341. }
  342. // WHAT A LIST SHOWS: the identity name; without one, nickname, then first + last name,
  343. // then "Identity <n>" (n = its place in the list)
  344. static labelOf = (r, n) => {
  345. if (str(r.identityName) != '') { return r.identityName }
  346. if (str(r.nickname) != '') { return r.nickname }
  347. let full = (str(r.firstname) + ' ' + str(r.lastname)).trim()
  348. if (full != '') { return full }
  349. return 'Identity ' + n
  350. }
  351. static identityRows = (accountId) => {
  352. let recs = identityRecords(accountId)
  353. let out = []
  354. let n = 1
  355. for (r of recs) {
  356. let full = (str(r.firstname) + ' ' + str(r.lastname)).trim()
  357. let parts = []
  358. if (str(r.nickname) != '') { parts.push('“' + r.nickname + '”') }
  359. if (full != '') { parts.push(full) }
  360. out.push({
  361. id = r.id
  362. shortId = str(r.shortId)
  363. label = labelOf(r, n)
  364. identityName = str(r.identityName)
  365. nickname = str(r.nickname)
  366. firstname = str(r.firstname)
  367. lastname = str(r.lastname)
  368. avatar = shownAvatar(r)
  369. hasAvatar = shownAvatar(r) != ''
  370. details = parts.length > 0 ? parts.join(' · ') : 'no names'
  371. isDefault = n == 1
  372. canDelete = recs.length > 1
  373. })
  374. n = n + 1
  375. }
  376. return out
  377. }
  378. // the identity `id` if it belongs to the account, else null
  379. static ownIdentity = (accountId, id) => {
  380. if (!isId(id)) { return null }
  381. let r = identitiesTable.fetch(id)
  382. if (r == null || r.account != accountId) { return null }
  383. return r
  384. }
  385. // answers { error, field } or { identity (public id) }
  386. static createIdentity = (accountId, fields) => {
  387. let c = checkFields(fields)
  388. if (c.error != null) { return c }
  389. let rec = { account = accountId shortId = freshShortId() identityName = '' nickname = '' firstname = '' lastname = '' avatar = '' created = now() updated = now() }
  390. for (k of c.fields.keys()) { rec[k] = c.fields[k] }
  391. let id = identitiesTable.put(rec)
  392. return { identity = id }
  393. }
  394. // only the fields named change; answers { error, field } or { identity }
  395. static updateIdentity = (accountId, id, fields) => {
  396. let r = ownIdentity(accountId, id)
  397. if (r == null) { return { error = 'no such identity' field = 'id' } }
  398. let c = checkFields(fields)
  399. if (c.error != null) { return c }
  400. let changes = { updated = now() }
  401. for (k of c.fields.keys()) { changes[k] = c.fields[k] }
  402. identitiesTable.update(r.id, merged(r, changes))
  403. return { identity = id }
  404. }
  405. // THE LAST IDENTITY STAYS: answers { error } or { deleted }
  406. static deleteIdentity = (accountId, id) => {
  407. let r = ownIdentity(accountId, id)
  408. if (r == null) { return { error = 'no such identity' } }
  409. if (identityRecords(accountId).length <= 1) { return { error = 'this is your only identity — an account always keeps one' } }
  410. identitiesTable.delete(r.id)
  411. return { deleted = id }
  412. }
  413. // ---- the client's IP bucket (mission 010) -------------------------------------------
  414. // `ip` is the X-Client-IP header nginx sets on Byrodin (CF-Connecting-IP when the request
  415. // really came through Cloudflare, else the TCP peer; nginx OVERWRITES what a client sent),
  416. // or null when there is none (dev without nginx: hl:web's req.remoteAddress would be nginx's; hl:http1 did not expose the
  417. // connection's peer address). The bucket:
  418. // no header / empty → 'direct' ONE SHARED BUCKET for every header-less request
  419. // IPv4 (also ::ffff:a.b.c.d) → the address
  420. // IPv6 → its /64 prefix ('2a01:4f9:3080:1126::/64'): one client
  421. // usually owns a whole /64, so per-address would not limit it
  422. // anything else → the text itself (lowercased, cut to 64 chars)
  423. static hexDigits = '0123456789abcdef'
  424. static stripZeros = (g) => {
  425. let t = g
  426. while (t.length > 1 && t[0] == '0') { t = t.slice(1, t.length) }
  427. return t
  428. }
  429. static ipBucket = (ip) => {
  430. if (ip == null || hlTypeName(ip) != 'String') { return 'direct' }
  431. let t = ip.trim().toLowerCase()
  432. if (t == '') { return 'direct' }
  433. if (t.length > 64) { t = t.slice(0, 64) }
  434. if (!t.includes(':')) { return t }
  435. // an IPv4-mapped IPv6 address counts as its IPv4 address
  436. if (t.includes('.')) { return t.slice(t.lastIndexOf(':') + 1, t.length) }
  437. let head = t
  438. let tail = ''
  439. let gap = t.indexOf('::')
  440. if (gap >= 0) {
  441. head = t.slice(0, gap)
  442. tail = t.slice(gap + 2, t.length)
  443. }
  444. let groups = head == '' ? [] : head.split(':')
  445. let rest = tail == '' ? [] : tail.split(':')
  446. if (gap >= 0) {
  447. let fill = 8 - groups.length - rest.length
  448. while (fill > 0) {
  449. groups.push('0')
  450. fill = fill - 1
  451. }
  452. }
  453. for (g of rest) { groups.push(g) }
  454. if (groups.length != 8) { return t }
  455. let out = []
  456. let i = 0
  457. while (i < 4) {
  458. let g = groups[i]
  459. if (g == '' || g.length > 4) { return t }
  460. let j = 0
  461. while (j < g.length) {
  462. if (!hexDigits.includes(g[j])) { return t }
  463. j = j + 1
  464. }
  465. out.push(stripZeros(g))
  466. i = i + 1
  467. }
  468. return out.join(':') + '::/64'
  469. }
  470. // how many codes went to this bucket in the short and in the long window (and the rows
  471. // older than the long window are removed on the way)
  472. static ipCounts = (bucket, t0) => {
  473. let inWindow = 0
  474. let inDay = 0
  475. let rows = ipSendsTable.find('ip', bucket)
  476. if (countOf(rows) > 0) {
  477. for (s of rows) {
  478. if (s.at > t0 - ipDayWindow) {
  479. inDay = inDay + 1
  480. if (s.at > t0 - ipWindow) { inWindow = inWindow + 1 }
  481. } else {
  482. ipSendsTable.delete(s.id)
  483. }
  484. }
  485. }
  486. return { inWindow = inWindow inDay = inDay }
  487. }
  488. // ---- step 1: ask for a code ---------------------------------------------------------
  489. // answers { error, limited } or { email, code } — the caller mails the code. The answer
  490. // does not say whether the address has an account (the first login creates it).
  491. // `ip` is the client's IP (the X-Client-IP header, or null — see ipBucket). `limited` is
  492. // true when a limit refused it (the route answers 429 then).
  493. static startLogin = (email, ip) => {
  494. let e = normEmail(email)
  495. if (!validEmail(e)) { return { error = 'that is not an email address' } }
  496. let t0 = now()
  497. // THE PER-IP LIMIT first: one client, many addresses
  498. let bucket = ipBucket(ip)
  499. let ipc = ipCounts(bucket, t0)
  500. if (ipc.inWindow >= ipLimit || ipc.inDay >= ipDayLimit) {
  501. return { error = 'too many codes were requested from your network — wait a while and try again' limited = true }
  502. }
  503. let recent = 0
  504. let sends = sendsTable.find('email', e)
  505. if (countOf(sends) > 0) {
  506. for (s of sends) {
  507. if (s.at > t0 - sendWindow) { recent = recent + 1 } else { sendsTable.delete(s.id) }
  508. }
  509. }
  510. if (recent >= sendLimit) {
  511. return { error = 'too many codes were sent to this address — wait a few minutes and try again' limited = true }
  512. }
  513. let olds = otpTable.find('email', e)
  514. if (countOf(olds) > 0) { for (old of olds) { otpTable.delete(old.id) } }
  515. let code = newOtp()
  516. otpTable.put({ email = e hash = otpHash(e, code) expires = t0 + otpTtl tries = 0 })
  517. sendsTable.put({ email = e at = t0 })
  518. ipSendsTable.put({ ip = bucket at = t0 })
  519. return { email = e code = code }
  520. }
  521. // ---- step 2: check it — and on the FIRST login create the account -------------------
  522. // answers { error } or { account, created }. `timeZone` is the browser's (first use);
  523. // a missing or malformed one is stored as UTC, it can be changed in ident.
  524. static checkCode = (email, code, timeZone) => {
  525. let e = normEmail(email)
  526. let c = code == null ? '' : ('' + code).trim()
  527. let p = first(otpTable.find('email', e))
  528. if (p == null) { return { error = 'no code is waiting for this address — ask for a new one' } }
  529. if (p.expires < now()) {
  530. otpTable.delete(p.id)
  531. return { error = 'the code expired — ask for a new one' }
  532. }
  533. if (otpHash(e, c) != p.hash) {
  534. if (p.tries + 1 >= maxTries) {
  535. otpTable.delete(p.id)
  536. return { error = 'too many wrong codes — ask for a new one' }
  537. }
  538. otpTable.update(p.id, merged(p, { tries = p.tries + 1 }))
  539. return { error = 'wrong code (' + (maxTries - p.tries - 1) + ' tries left)' }
  540. }
  541. otpTable.delete(p.id)
  542. let known = accountByEmail(e)
  543. if (known != null) { return { account = known created = false } }
  544. let tz = checkTimeZone(timeZone)
  545. let id = accountsTable.put({ email = e timeZone = tz.error == null ? tz.timeZone : 'UTC' created = now() })
  546. // THE DEFAULT IDENTITY, from the beginning; its names are optional (asked next)
  547. identitiesTable.put({ account = id shortId = freshShortId() identityName = 'Default' nickname = '' firstname = '' lastname = '' avatar = '' created = now() updated = now() })
  548. return { account = accountRowOf(accountsTable.fetch(id)) created = true }
  549. }
  550. // ---- THE PENDING SIGN-IN (ticket ident#20, mission 032) ------------------------------
  551. // After "Send me a code" the browser's SESSION remembers the address, so a reload (a phone
  552. // reloading the tab while the user reads the mail, a second tab, a socket re-seed) still
  553. // shows the code step. It lives in `session.data.pendingEmail` (the app's hybrid on the
  554. // Session — an undeclared member of the instance would not read back, see session.hl).
  555. // It is ONLY honoured while a code for that address is really waiting: in otpTable, not
  556. // expired, not used, not killed by too many wrong tries — so it never shows a code step
  557. // for an address no code was sent to. It holds no secret (the code is only in the mail).
  558. // Cleared on: a successful sign-in (verifyCode), "Other address" (dropPending), and
  559. // expiry / a dead code (pendingOf drops it the next time it reads it).
  560. static codeWaiting = (email) => {
  561. let e = normEmail(email)
  562. if (e == '') { return false }
  563. let p = first(otpTable.find('email', e))
  564. return p != null && p.expires >= now() && p.tries < maxTries
  565. }
  566. // the face rememberPending (home.hl): { email } or { error }
  567. static recordPending = (session, email) => {
  568. if (session == null) { return { error = 'no session — reload the page' } }
  569. if (email == null || hlTypeName(email) != 'String') { return { error = 'field email must be a string' } }
  570. let e = normEmail(email)
  571. if (!codeWaiting(e)) { return { error = 'no code is waiting for this address' } }
  572. session.data.pendingEmail = e
  573. return { email = e }
  574. }
  575. // the address whose code step this session shows, or null (and a stale one is dropped)
  576. static pendingOf = (session) => {
  577. if (session == null || session.data == null) { return null }
  578. let e = session.data.pendingEmail
  579. if (e == null || e == '') { return null }
  580. if (!codeWaiting(e)) {
  581. session.data.pendingEmail = null
  582. return null
  583. }
  584. return e
  585. }
  586. static dropPending = (session) => {
  587. if (session != null && session.data != null) { session.data.pendingEmail = null }
  588. return true
  589. }

Branches

Latest commits

  • 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