gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit7423581574235815ident mission 009 (4/4): docs (README files map + same-output test, STATUS, LOG), report, tests/letcount.py + realdata-baseline/comparemre74235815/lib/accounts.hl

6.6 KB

  1. // lib/accounts.hl — THE ACCOUNT and ITS SESSIONS (piece 1, ticket #24; hardening ticket #2; sign out everywhere
  2. // #19): the accounts table and every write to it, signing a session in, reading the account a session carries,
  3. // signing out everywhere, the time zone. Statics only, the server realm.
  4. // Concept: CONCEPT.md (the creator's; source of truth).
  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, sessionEpoch }
  15. //
  16. // NO REGISTRATION: the first right code for an address (lib/login.hl checkCode) creates the account AND its
  17. // default identity (createAccount).
  18. import { MPackDB } from 'hl:mpackdb'
  19. import { now } from 'hl:time'
  20. import { listDir, readFile, remove } from 'hl:fs'
  21. import { dir, envNumber, first, merged, isId, normEmail, checkTimeZone } from './util.hl'
  22. import { addDefaultIdentity } from './identities.hl'
  23. static accountsTable = new MPackDB(file = dir + '/accounts.db', primaryKey = '@id', indexes = ['!email'])
  24. // A SIGNED-IN SESSION'S OWN EXPIRY (ticket #2, hardening): independent of the hl:web
  25. // session file's rolling idle/maxAge, so a browser left open cannot stay signed in to
  26. // ident forever — the account's OTP must be proven again after this many ms.
  27. static sessionUserTtl = envNumber('IDENT_SESSION_TTL_MS', 1209600000) // 14 days
  28. // PUBLIC ACCOUNT / IDENTITY IDS are the records' UUIDs
  29. static accountRowOf = (a) => { return a == null ? null : { id = a.id email = a.email timeZone = a.timeZone } }
  30. static accountById = (id) => { return isId(id) ? accountRowOf(accountsTable.fetch(id)) : null }
  31. static accountByEmail = (email) => { return accountRowOf(first(accountsTable.find('email', normEmail(email)))) }
  32. // THE FIRST LOGIN of an address: the account (the browser's time zone; a missing or malformed one is
  33. // stored as UTC, it can be changed in ident) and THE DEFAULT IDENTITY, from the beginning. Answers the account row.
  34. static createAccount = (e, timeZone) => {
  35. tz = checkTimeZone(timeZone)
  36. id = accountsTable.put({ email = e timeZone = tz.error == null ? tz.timeZone : 'UTC' created = now() })
  37. addDefaultIdentity(id)
  38. return accountRowOf(accountsTable.fetch(id))
  39. }
  40. // the account a session carries, re-read from the table (null when signed out or gone).
  41. // ONLY A REAL SESSION COUNTS: a face's trailing `session` argument is positional, so a
  42. // hand-made emit with one argument too many would hand the face its own object in the
  43. // session's place (see README "Lessons"); the framework's session is a class instance (hlTypeName 'Instance'), JSON never is.
  44. // SIGNS A SESSION IN (ticket #2): stamps `since` (this session's own expiry, sessionUserTtl)
  45. // and `epoch` (the account's current sessionEpoch — "sign out everywhere" bumps it, which
  46. // makes every session that still carries the old epoch read as signed out, see below).
  47. // `&session` on every lambda here that writes the session: since hybriel #48 a lambda parameter COPIES its
  48. // argument (the framework Session instance included), so without `&` the write lands on a copy.
  49. static accountEpoch = (a) => { return a.sessionEpoch == null ? 0 : a.sessionEpoch }
  50. static beginSession = (&session, accountId) => {
  51. if (session == null) { return false }
  52. a = isId(accountId) ? accountsTable.fetch(accountId) : null
  53. if (a == null) { return false }
  54. session.user = { id = accountId since = now() epoch = accountEpoch(a) }
  55. return true
  56. }
  57. static accountOfSession = (&session) => {
  58. if (session == null || session.user == null) { return null }
  59. a = accountsTable.fetch(session.user.id)
  60. if (a == null) { session.user = null return null }
  61. // AN OLDER SESSION (before ticket #2, incl. one carried across the mission-009
  62. // migration): it has no `since`/`epoch` yet. Adopt them now instead of forcing
  63. // everyone signed out the moment this ships — its own-expiry clock starts here.
  64. if (session.user.since == null || session.user.epoch == null) {
  65. session.user = { id = session.user.id since = now() epoch = accountEpoch(a) }
  66. return accountRowOf(a)
  67. }
  68. if (now() - session.user.since > sessionUserTtl || session.user.epoch != accountEpoch(a)) {
  69. session.user = null
  70. return null
  71. }
  72. return accountRowOf(a)
  73. }
  74. // "SIGN OUT EVERYWHERE": bumps the account's sessionEpoch, so every session that carries
  75. // this account (this one included — the caller's UI resets the same as a normal sign out)
  76. // stops working at its next check, on every device, without touching any other session file.
  77. // The session files themselves are DELETED too (project.hl hands in `dropUser` = dropUserSessions
  78. // below on its server); the epoch stays as the second net for any session the sweep did not see.
  79. static sessionHooks = { dropUser = null }
  80. static signOutEverywhere = (accountId) => {
  81. a = isId(accountId) ? accountsTable.fetch(accountId) : null
  82. if (a == null) { return { error = 'no such account' } }
  83. if (sessionHooks.dropUser != null) { sessionHooks.dropUser(accountId) }
  84. accountsTable.update(a.id, merged(a, { sessionEpoch = accountEpoch(a) + 1 }))
  85. return { ok = true }
  86. }
  87. // ---- DELETE EVERY SESSION OF AN ACCOUNT (ticket #19) — `srv` is project.hl's server -------
  88. // Resident ones (dropped from memory, their user cleared so a still-open socket's session
  89. // object cannot be written back signed in) and the files of the ones nobody has open.
  90. static dropUserSessions = (&srv, userId) => {
  91. sess = srv.sessions
  92. gone = []
  93. for (k of sess.map.keys()) {
  94. let x = sess.map[k]
  95. if (x != null && x.user != null && x.user.id == userId) { gone.push(k) }
  96. }
  97. for (k of gone) {
  98. sess.map[k].user = null
  99. sess.drop(k)
  100. sess.map[k] = null
  101. }
  102. if (sess.dir != null) {
  103. for (e of listDir(sess.dir)) {
  104. let raw = readFile(e.path)
  105. if (raw != null) {
  106. let rec = JSON.parse(raw)
  107. if (rec != null && rec.user != null && rec.user.id == userId) { remove(e.path) }
  108. }
  109. }
  110. }
  111. return null
  112. }
  113. static setTimeZone = (accountId, tz) => {
  114. c = checkTimeZone(tz)
  115. if (c.error != null) { return c }
  116. a = isId(accountId) ? accountsTable.fetch(accountId) : null
  117. if (a == null) { return { error = 'no such account' } }
  118. accountsTable.update(a.id, merged(a, { timeZone = c.timeZone }))
  119. return { account = accountRowOf(accountsTable.fetch(a.id)) }
  120. }

Branches

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