gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitf8bdcbc2f8bdcbc2ident: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); all gates greenmref8bdcbc2/store.hl

27.6 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. // `&session` on every lambda here that writes the session: since hybriel #48 a lambda parameter COPIES its
  231. // argument (the framework Session instance included), so without `&` the write lands on a copy.
  232. static accountEpoch = (a) => { return a.sessionEpoch == null ? 0 : a.sessionEpoch }
  233. static beginSession = (&session, accountId) => {
  234. if (session == null) { return false }
  235. let a = isId(accountId) ? accountsTable.fetch(accountId) : null
  236. if (a == null) { return false }
  237. session.user = { id = accountId since = now() epoch = accountEpoch(a) }
  238. return true
  239. }
  240. static accountOfSession = (&session) => {
  241. if (session == null || session.user == null) { return null }
  242. let a = accountsTable.fetch(session.user.id)
  243. if (a == null) { session.user = null return null }
  244. // AN OLDER SESSION (before ticket #2, incl. one carried across the mission-009
  245. // migration): it has no `since`/`epoch` yet. Adopt them now instead of forcing
  246. // everyone signed out the moment this ships — its own-expiry clock starts here.
  247. if (session.user.since == null || session.user.epoch == null) {
  248. session.user = { id = session.user.id since = now() epoch = accountEpoch(a) }
  249. return accountRowOf(a)
  250. }
  251. if (now() - session.user.since > sessionUserTtl || session.user.epoch != accountEpoch(a)) {
  252. session.user = null
  253. return null
  254. }
  255. return accountRowOf(a)
  256. }
  257. // "SIGN OUT EVERYWHERE": bumps the account's sessionEpoch, so every session that carries
  258. // this account (this one included — the caller's UI resets the same as a normal sign out)
  259. // stops working at its next check, on every device, without touching any other session file.
  260. // The session files themselves are DELETED too (project.hl hands in `dropUser`, it owns the
  261. // session store); the epoch stays as the second net for any session the sweep did not see.
  262. static sessionHooks = { dropUser = null }
  263. static signOutEverywhere = (accountId) => {
  264. let a = isId(accountId) ? accountsTable.fetch(accountId) : null
  265. if (a == null) { return { error = 'no such account' } }
  266. if (sessionHooks.dropUser != null) { sessionHooks.dropUser(accountId) }
  267. accountsTable.update(a.id, merged(a, { sessionEpoch = accountEpoch(a) + 1 }))
  268. return { ok = true }
  269. }
  270. static setTimeZone = (accountId, tz) => {
  271. let c = checkTimeZone(tz)
  272. if (c.error != null) { return c }
  273. let a = isId(accountId) ? accountsTable.fetch(accountId) : null
  274. if (a == null) { return { error = 'no such account' } }
  275. accountsTable.update(a.id, merged(a, { timeZone = c.timeZone }))
  276. return { account = accountRowOf(accountsTable.fetch(a.id)) }
  277. }
  278. // ---- THE SHORT ID (ident#23) ------------------------------------------------------------
  279. // ONE PUBLIC ID per identity, kept forever, the same in every app: 5 characters (e.g. a68sz)
  280. // from an alphabet without look-alikes — digits 2-9 and the letters minus i, l, o (no 0/O,
  281. // 1/l/I): 31 characters, 28.6 million ids. Case does not matter (stored lowercase, looked up
  282. // lowercased). Unique: checked against the `!shortId` index when it is made. Random (not
  283. // counted), so it says nothing about how many identities exist.
  284. static shortAlphabet = '23456789abcdefghjkmnpqrstuvwxyz'
  285. static shortLength = 5
  286. static newShortId = () => {
  287. let out = ''
  288. while (out.length < shortLength) {
  289. let hex = randomBytes(16)
  290. let i = 0
  291. while (i < 32 && out.length < shortLength) {
  292. let b = '0123456789abcdef'.indexOf(hex[i]) * 16 + '0123456789abcdef'.indexOf(hex[i + 1])
  293. // 248 = 8 * 31: bytes above it are dropped so every character is equally likely
  294. if (b < 248) { out = out + shortAlphabet[b % 31] }
  295. i = i + 2
  296. }
  297. }
  298. return out
  299. }
  300. // a fresh short id nobody has
  301. static freshShortId = () => {
  302. let id = newShortId()
  303. while (countOf(identitiesTable.find('shortId', id)) > 0) { id = newShortId() }
  304. return id
  305. }
  306. // what a person typed → the lowercase form, or null when it cannot be a short id
  307. static normShortId = (v) => {
  308. if (v == null || hlTypeName(v) != 'String') { return null }
  309. let t = v.trim().toLowerCase()
  310. if (t.length != shortLength) { return null }
  311. let i = 0
  312. while (i < t.length) {
  313. if (shortAlphabet.indexOf(t[i]) < 0) { return null }
  314. i = i + 1
  315. }
  316. return t
  317. }
  318. static identityByShortId = (v) => {
  319. let t = normShortId(v)
  320. return t == null ? null : first(identitiesTable.find('shortId', t))
  321. }
  322. // every identity made before ident#23 gets its short id now, once (at load)
  323. static backfillShortIds = () => {
  324. let n = 0
  325. let rows = identitiesTable.find(null, null)
  326. if (countOf(rows) > 0) {
  327. for (r of rows) {
  328. if (r.shortId == null || r.shortId == '') {
  329. identitiesTable.update(r.id, merged(r, { shortId = freshShortId() }))
  330. n = n + 1
  331. }
  332. }
  333. }
  334. return n
  335. }
  336. static shortIdsBackfilled = backfillShortIds()
  337. // ---- identities -----------------------------------------------------------------------
  338. static str = (v) => { return v == null ? '' : v }
  339. // the account's identities, oldest first (stored `created`); the first is the DEFAULT one
  340. static identityRecords = (accountId) => {
  341. if (!isId(accountId)) { return [] }
  342. return oldestFirst(identitiesTable.find('account', accountId))
  343. }
  344. // WHAT A LIST SHOWS: the identity name; without one, nickname, then first + last name,
  345. // then "Identity <n>" (n = its place in the list)
  346. static labelOf = (r, n) => {
  347. if (str(r.identityName) != '') { return r.identityName }
  348. if (str(r.nickname) != '') { return r.nickname }
  349. let full = (str(r.firstname) + ' ' + str(r.lastname)).trim()
  350. if (full != '') { return full }
  351. return 'Identity ' + n
  352. }
  353. static identityRows = (accountId) => {
  354. let recs = identityRecords(accountId)
  355. let out = []
  356. let n = 1
  357. for (r of recs) {
  358. let full = (str(r.firstname) + ' ' + str(r.lastname)).trim()
  359. let parts = []
  360. if (str(r.nickname) != '') { parts.push('“' + r.nickname + '”') }
  361. if (full != '') { parts.push(full) }
  362. out.push({
  363. id = r.id
  364. shortId = str(r.shortId)
  365. label = labelOf(r, n)
  366. identityName = str(r.identityName)
  367. nickname = str(r.nickname)
  368. firstname = str(r.firstname)
  369. lastname = str(r.lastname)
  370. avatar = shownAvatar(r)
  371. hasAvatar = shownAvatar(r) != ''
  372. details = parts.length > 0 ? parts.join(' · ') : 'no names'
  373. isDefault = n == 1
  374. canDelete = recs.length > 1
  375. })
  376. n = n + 1
  377. }
  378. return out
  379. }
  380. // the identity `id` if it belongs to the account, else null
  381. static ownIdentity = (accountId, id) => {
  382. if (!isId(id)) { return null }
  383. let r = identitiesTable.fetch(id)
  384. if (r == null || r.account != accountId) { return null }
  385. return r
  386. }
  387. // answers { error, field } or { identity (public id) }
  388. static createIdentity = (accountId, fields) => {
  389. let c = checkFields(fields)
  390. if (c.error != null) { return c }
  391. let rec = { account = accountId shortId = freshShortId() identityName = '' nickname = '' firstname = '' lastname = '' avatar = '' created = now() updated = now() }
  392. for (k of c.fields.keys()) { rec[k] = c.fields[k] }
  393. let id = identitiesTable.put(rec)
  394. return { identity = id }
  395. }
  396. // only the fields named change; answers { error, field } or { identity }
  397. static updateIdentity = (accountId, id, fields) => {
  398. let r = ownIdentity(accountId, id)
  399. if (r == null) { return { error = 'no such identity' field = 'id' } }
  400. let c = checkFields(fields)
  401. if (c.error != null) { return c }
  402. let changes = { updated = now() }
  403. for (k of c.fields.keys()) { changes[k] = c.fields[k] }
  404. identitiesTable.update(r.id, merged(r, changes))
  405. return { identity = id }
  406. }
  407. // THE LAST IDENTITY STAYS: answers { error } or { deleted }
  408. static deleteIdentity = (accountId, id) => {
  409. let r = ownIdentity(accountId, id)
  410. if (r == null) { return { error = 'no such identity' } }
  411. if (identityRecords(accountId).length <= 1) { return { error = 'this is your only identity — an account always keeps one' } }
  412. identitiesTable.delete(r.id)
  413. return { deleted = id }
  414. }
  415. // ---- the client's IP bucket (mission 010) -------------------------------------------
  416. // `ip` is the X-Client-IP header nginx sets on Byrodin (CF-Connecting-IP when the request
  417. // really came through Cloudflare, else the TCP peer; nginx OVERWRITES what a client sent),
  418. // or null when there is none (dev without nginx: hl:web's req.remoteAddress would be nginx's; hl:http1 did not expose the
  419. // connection's peer address). The bucket:
  420. // no header / empty → 'direct' ONE SHARED BUCKET for every header-less request
  421. // IPv4 (also ::ffff:a.b.c.d) → the address
  422. // IPv6 → its /64 prefix ('2a01:4f9:3080:1126::/64'): one client
  423. // usually owns a whole /64, so per-address would not limit it
  424. // anything else → the text itself (lowercased, cut to 64 chars)
  425. static hexDigits = '0123456789abcdef'
  426. static stripZeros = (g) => {
  427. let t = g
  428. while (t.length > 1 && t[0] == '0') { t = t.slice(1, t.length) }
  429. return t
  430. }
  431. static ipBucket = (ip) => {
  432. if (ip == null || hlTypeName(ip) != 'String') { return 'direct' }
  433. let t = ip.trim().toLowerCase()
  434. if (t == '') { return 'direct' }
  435. if (t.length > 64) { t = t.slice(0, 64) }
  436. if (!t.includes(':')) { return t }
  437. // an IPv4-mapped IPv6 address counts as its IPv4 address
  438. if (t.includes('.')) { return t.slice(t.lastIndexOf(':') + 1, t.length) }
  439. let head = t
  440. let tail = ''
  441. let gap = t.indexOf('::')
  442. if (gap >= 0) {
  443. head = t.slice(0, gap)
  444. tail = t.slice(gap + 2, t.length)
  445. }
  446. let groups = head == '' ? [] : head.split(':')
  447. let rest = tail == '' ? [] : tail.split(':')
  448. if (gap >= 0) {
  449. let fill = 8 - groups.length - rest.length
  450. while (fill > 0) {
  451. groups.push('0')
  452. fill = fill - 1
  453. }
  454. }
  455. for (g of rest) { groups.push(g) }
  456. if (groups.length != 8) { return t }
  457. let out = []
  458. let i = 0
  459. while (i < 4) {
  460. let g = groups[i]
  461. if (g == '' || g.length > 4) { return t }
  462. let j = 0
  463. while (j < g.length) {
  464. if (!hexDigits.includes(g[j])) { return t }
  465. j = j + 1
  466. }
  467. out.push(stripZeros(g))
  468. i = i + 1
  469. }
  470. return out.join(':') + '::/64'
  471. }
  472. // how many codes went to this bucket in the short and in the long window (and the rows
  473. // older than the long window are removed on the way)
  474. static ipCounts = (bucket, t0) => {
  475. let inWindow = 0
  476. let inDay = 0
  477. let rows = ipSendsTable.find('ip', bucket)
  478. if (countOf(rows) > 0) {
  479. for (s of rows) {
  480. if (s.at > t0 - ipDayWindow) {
  481. inDay = inDay + 1
  482. if (s.at > t0 - ipWindow) { inWindow = inWindow + 1 }
  483. } else {
  484. ipSendsTable.delete(s.id)
  485. }
  486. }
  487. }
  488. return { inWindow = inWindow inDay = inDay }
  489. }
  490. // ---- step 1: ask for a code ---------------------------------------------------------
  491. // answers { error, limited } or { email, code } — the caller mails the code. The answer
  492. // does not say whether the address has an account (the first login creates it).
  493. // `ip` is the client's IP (the X-Client-IP header, or null — see ipBucket). `limited` is
  494. // true when a limit refused it (the route answers 429 then).
  495. static startLogin = (email, ip) => {
  496. let e = normEmail(email)
  497. if (!validEmail(e)) { return { error = 'that is not an email address' } }
  498. let t0 = now()
  499. // THE PER-IP LIMIT first: one client, many addresses
  500. let bucket = ipBucket(ip)
  501. let ipc = ipCounts(bucket, t0)
  502. if (ipc.inWindow >= ipLimit || ipc.inDay >= ipDayLimit) {
  503. return { error = 'too many codes were requested from your network — wait a while and try again' limited = true }
  504. }
  505. let recent = 0
  506. let sends = sendsTable.find('email', e)
  507. if (countOf(sends) > 0) {
  508. for (s of sends) {
  509. if (s.at > t0 - sendWindow) { recent = recent + 1 } else { sendsTable.delete(s.id) }
  510. }
  511. }
  512. if (recent >= sendLimit) {
  513. return { error = 'too many codes were sent to this address — wait a few minutes and try again' limited = true }
  514. }
  515. let olds = otpTable.find('email', e)
  516. if (countOf(olds) > 0) { for (old of olds) { otpTable.delete(old.id) } }
  517. let code = newOtp()
  518. otpTable.put({ email = e hash = otpHash(e, code) expires = t0 + otpTtl tries = 0 })
  519. sendsTable.put({ email = e at = t0 })
  520. ipSendsTable.put({ ip = bucket at = t0 })
  521. return { email = e code = code }
  522. }
  523. // ---- step 2: check it — and on the FIRST login create the account -------------------
  524. // answers { error } or { account, created }. `timeZone` is the browser's (first use);
  525. // a missing or malformed one is stored as UTC, it can be changed in ident.
  526. static checkCode = (email, code, timeZone) => {
  527. let e = normEmail(email)
  528. let c = code == null ? '' : ('' + code).trim()
  529. let p = first(otpTable.find('email', e))
  530. if (p == null) { return { error = 'no code is waiting for this address — ask for a new one' } }
  531. if (p.expires < now()) {
  532. otpTable.delete(p.id)
  533. return { error = 'the code expired — ask for a new one' }
  534. }
  535. if (otpHash(e, c) != p.hash) {
  536. if (p.tries + 1 >= maxTries) {
  537. otpTable.delete(p.id)
  538. return { error = 'too many wrong codes — ask for a new one' }
  539. }
  540. otpTable.update(p.id, merged(p, { tries = p.tries + 1 }))
  541. return { error = 'wrong code (' + (maxTries - p.tries - 1) + ' tries left)' }
  542. }
  543. otpTable.delete(p.id)
  544. let known = accountByEmail(e)
  545. if (known != null) { return { account = known created = false } }
  546. let tz = checkTimeZone(timeZone)
  547. let id = accountsTable.put({ email = e timeZone = tz.error == null ? tz.timeZone : 'UTC' created = now() })
  548. // THE DEFAULT IDENTITY, from the beginning; its names are optional (asked next)
  549. identitiesTable.put({ account = id shortId = freshShortId() identityName = 'Default' nickname = '' firstname = '' lastname = '' avatar = '' created = now() updated = now() })
  550. return { account = accountRowOf(accountsTable.fetch(id)) created = true }
  551. }
  552. // ---- THE PENDING SIGN-IN (ticket ident#20, mission 032) ------------------------------
  553. // After "Send me a code" the browser's SESSION remembers the address, so a reload (a phone
  554. // reloading the tab while the user reads the mail, a second tab, a socket re-seed) still
  555. // shows the code step. It lives in `session.data.pendingEmail` (the app's hybrid on the
  556. // Session — an undeclared member of the instance would not read back, see session.hl).
  557. // It is ONLY honoured while a code for that address is really waiting: in otpTable, not
  558. // expired, not used, not killed by too many wrong tries — so it never shows a code step
  559. // for an address no code was sent to. It holds no secret (the code is only in the mail).
  560. // Cleared on: a successful sign-in (verifyCode), "Other address" (dropPending), and
  561. // expiry / a dead code (pendingOf drops it the next time it reads it).
  562. static codeWaiting = (email) => {
  563. let e = normEmail(email)
  564. if (e == '') { return false }
  565. let p = first(otpTable.find('email', e))
  566. return p != null && p.expires >= now() && p.tries < maxTries
  567. }
  568. // the face rememberPending (home.hl): { email } or { error }
  569. static recordPending = (&session, email) => {
  570. if (session == null) { return { error = 'no session — reload the page' } }
  571. if (email == null || hlTypeName(email) != 'String') { return { error = 'field email must be a string' } }
  572. let e = normEmail(email)
  573. if (!codeWaiting(e)) { return { error = 'no code is waiting for this address' } }
  574. session.data.pendingEmail = e
  575. return { email = e }
  576. }
  577. // the address whose code step this session shows, or null (and a stale one is dropped)
  578. static pendingOf = (&session) => {
  579. if (session == null || session.data == null) { return null }
  580. let e = session.data.pendingEmail
  581. if (e == null || e == '') { return null }
  582. if (!codeWaiting(e)) {
  583. session.data.pendingEmail = null
  584. return null
  585. }
  586. return e
  587. }
  588. static dropPending = (&session) => {
  589. if (session != null && session.data != null) { session.data.pendingEmail = null }
  590. return true
  591. }

Branches

Latest commits

  • 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