gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Main branchmain5fdbb6b2ident mission 009: report — scratch folder notemremain/components/home.hl

21.1 KB

  1. // components/home.hl — `/` and `/signin/:rid`: ALL OF IDENT (pieces 1+2, tickets #24 #25;
  2. // CONCEPT.md).
  3. // Signed out: email → a six-digit code by mail → signed in. There is no registration:
  4. // the first right code for an address creates the account with a DEFAULT IDENTITY, then
  5. // this page shows that identity's name fields and says they are optional (Skip).
  6. // Signed in: the account's identities (list, new, edit, delete — never the last one)
  7. // and its time zone (the browser's at the first login; changeable here).
  8. //
  9. // Under `/signin/<rid>` the page belongs to an APP'S LOGIN BUTTON (lib/apps.hl requestOf):
  10. // it names the app, signs in to ident if needed, then asks WHICH IDENTITY the app gets
  11. // (with a single identity it still shows which one, one click) and sends the browser back
  12. // to the app with a one-time code. Managing identities stays on `/`.
  13. //
  14. // Every step takes the server's answer from the CALL (the value form of emit): it rides
  15. // the ack, over the socket or the POST fallback.
  16. parent './main.hl'
  17. import { accountOfSession, setTimeZone, signOutEverywhere } from '../lib/accounts.hl'
  18. import { recordPending, dropPending, signInWithCode } from '../lib/login.hl'
  19. import { identityRows, createIdentity, updateIdentity, deleteIdentity } from '../lib/identities.hl'
  20. import { requestOf, grantRequest } from '../lib/apps.hl'
  21. import { siteName } from '../project.hl'
  22. session = null
  23. rid = null
  24. // 'code' on THE CODE PAGE `/code` and `/signin/<rid>/code` (ident#20; lib/api.hl codePage
  25. // renders this component there, only while the session has a pending code), else null
  26. step = null
  27. onCodePage = step == 'code'
  28. // the pending address, read by codePage from the session and handed in as a param (mission
  29. // 048): read here from the session, hl:web (64527baa) would re-derive it after EVERY face
  30. // that takes the session — after the 5th wrong code it turned null and the code form
  31. // vanished with nothing to click (gate "after 5 wrong tries even the RIGHT code is refused")
  32. pending = null
  33. // where this login lives: the email form, and the code page after "Send me a code"
  34. homeUrl = rid != null ? '/signin/' + rid : '/'
  35. codeUrl = rid != null ? '/signin/' + rid + '/code' : '/code'
  36. me = accountOfSession(session)
  37. signedIn = me != null
  38. // THE APP'S LOGIN REQUEST (null on `/`, or when unknown / expired)
  39. request = rid != null ? requestOf(rid) : null
  40. forApp = request != null
  41. badRequest = rid != null && request == null
  42. appName = request != null ? request.app.name : ''
  43. appOrigin = request != null ? request.origin : ''
  44. forInvite = request != null && request.invite != ''
  45. requestLead = forInvite ? 'You are invited to ' : 'Sign in to '
  46. choosing = forApp && me != null // the identity choice for the app
  47. manage = rid == null && me != null // identities + time zone (only on `/`)
  48. meEmail = me != null ? me.email : ''
  49. timeZone = me != null ? me.timeZone : ''
  50. tzInput = timeZone
  51. identities = me != null ? identityRows(me.id) : []
  52. __title = siteName + (forApp ? ' | sign in to ' + appName : (me != null ? ' | your identities' : ' | sign in'))
  53. // the login steps (a View's `if` takes a member, so each step is one).
  54. // THE CODE STEP IS ITS OWN PAGE (ident#20, creator: "just make a /code where it checks a
  55. // pending code"): "Send me a code" records the address in this browser's session (face
  56. // rememberPending) and goes to `/code`; that page shows the code form for the session's
  57. // pending address (lib/login.hl pendingOf) — so a reload, a second tab or a re-seed keeps it.
  58. pendingEmail = onCodePage && me == null && !badRequest ? pending : null
  59. askEmail = me == null && !badRequest && !onCodePage
  60. askCode = pendingEmail != null
  61. email = ''
  62. sentTo = pendingEmail != null ? pendingEmail : ''
  63. code = ''
  64. message = ''
  65. notice = ''
  66. // THE IDENTITY FORM — new, edit, and right after the first login the default identity
  67. // ("welcome": the names are optional, Skip closes it)
  68. editing = false
  69. welcome = false
  70. notWelcome = true // `if (!welcome)` in a View renders nothing: an `if` takes a member
  71. editId = '' // '' = a new identity, else the identity's id (a UUID)
  72. editHeading = ''
  73. fIdentityName = ''
  74. fNickname = ''
  75. fFirstname = ''
  76. fLastname = ''
  77. fAvatar = ''
  78. hasAvatarPreview = false
  79. avatarPreviewClass = hasAvatarPreview ? 'avatar' : 'avatar hidden'
  80. avatarRemoveClass = hasAvatarPreview ? 'quiet small' : 'quiet small hidden'
  81. View {
  82. identCard {
  83. if (forApp) {
  84. appRequest { id = "apprequest" requestLead strong { id = "appname" appName } " " requestOrigin { id = "apporigin" appOrigin } }
  85. }
  86. if (badRequest) {
  87. h1 { "Login request expired" }
  88. p { id = "badrequest" class = "message" "This login request is unknown or expired. Go back to the app and start the login again." }
  89. }
  90. if (askEmail) {
  91. h1 { "Sign in" }
  92. p { class = "lead" "We mail you a one-time code. There are no passwords, and no sign-up: the first login with an address creates its account." }
  93. form { id = "emailform"
  94. on submit(e) {
  95. e.preventDefault()
  96. emit ask(e)
  97. }
  98. label { "Email"
  99. input { id = "email" name = "email" type = "email" autocomplete = "email" required = "required" value = email on input(e) { emit setEmail(e.target.value) } }
  100. }
  101. button { id = "sendcode" type = "submit" "Send me a code" }
  102. }
  103. }
  104. if (askCode) {
  105. h1 { "Enter your code" }
  106. p { id = "sentto" class = "lead" "We sent a six-digit code to " strong { sentTo } ". It is valid for 10 minutes." }
  107. form { id = "codeform"
  108. on submit(e) {
  109. e.preventDefault()
  110. emit verify(e)
  111. }
  112. label { "One-time code"
  113. input { id = "code" name = "code" autocomplete = "one-time-code" inputmode = "numeric" pattern = "[0-9]{6}" maxlength = "6" required = "required" value = code on input(e) { emit setCode(e.target.value) } }
  114. }
  115. formButtons {
  116. button { id = "verify" type = "submit" "Sign in" }
  117. button { id = "back" type = "button" class = "quiet" "Other address" on click(e) { emit back(e) } }
  118. }
  119. }
  120. }
  121. if (signedIn) {
  122. accountBar {
  123. userEmail { id = "meemail" meEmail }
  124. button { id = "signout" type = "button" class = "quiet small" "Sign out" on click(e) { emit doSignOut(e) } }
  125. button { id = "signoutall" type = "button" class = "quiet small" "Sign out everywhere" on click(e) { emit doSignOutAll(e) } }
  126. }
  127. if (editing) {
  128. form { id = "identityform"
  129. on submit(e) {
  130. e.preventDefault()
  131. emit saveForm(e)
  132. }
  133. h1 { id = "formheading" editHeading }
  134. if (welcome) {
  135. p { id = "welcome" class = "lead" "Your account is ready and has a default identity. All of these fields are " strong { "optional" } " — fill in what you like, or skip it and do it later." }
  136. }
  137. label { "Identity name " fieldHint { "(shown when you pick an identity in an app; optional)" }
  138. input { id = "fidentityname" name = "identityName" maxlength = "60" autocomplete = "off" value = fIdentityName on input(e) { emit setF('identityName', e.target.value) } }
  139. }
  140. label { "Nickname " fieldHint { "(optional)" }
  141. input { id = "fnickname" name = "nickname" maxlength = "60" autocomplete = "nickname" value = fNickname on input(e) { emit setF('nickname', e.target.value) } }
  142. }
  143. label { "First name " fieldHint { "(optional)" }
  144. input { id = "ffirstname" name = "firstname" maxlength = "60" autocomplete = "given-name" value = fFirstname on input(e) { emit setF('firstname', e.target.value) } }
  145. }
  146. label { "Last name " fieldHint { "(optional)" }
  147. input { id = "flastname" name = "lastname" maxlength = "60" autocomplete = "family-name" value = fLastname on input(e) { emit setF('lastname', e.target.value) } }
  148. }
  149. avatarField {
  150. img { id = "avatarpreview" class = avatarPreviewClass src = fAvatar alt = "" width = "48" height = "48" }
  151. label { "Avatar " fieldHint { "(optional; a picture apps can show, e.g. next to your comments)" }
  152. input { id = "favatarfile" type = "file" accept = "image/png,image/jpeg,image/webp,image/gif" }
  153. }
  154. input { id = "favatar" name = "avatar" type = "hidden" value = fAvatar on input(e) { emit setF('avatar', e.target.value) } }
  155. button { id = "avatarremove" type = "button" class = avatarRemoveClass "Remove picture" on click(e) { emit setF('avatar', '') } }
  156. span { id = "avatarnote" class = "avatar-note" }
  157. }
  158. formButtons {
  159. button { id = "saveidentity" type = "submit" "Save" }
  160. if (welcome) { button { id = "skip" type = "button" class = "quiet" "Skip" on click(e) { emit closeForm(e) } } }
  161. if (notWelcome) { button { id = "cancel" type = "button" class = "quiet" "Cancel" on click(e) { emit closeForm(e) } } }
  162. }
  163. }
  164. }
  165. if (choosing) {
  166. section { id = "choosesection"
  167. h1 { "Choose an identity" }
  168. p { class = "lead" "Which identity signs in to " strong { appName } "? The app gets only the identity's short id — no email, no names." }
  169. ul { id = "chooselist"
  170. for (row of identities) {
  171. li { class = "choice"
  172. if (row.hasAvatar) { img { class = "avatar" src = row.avatar alt = "" width = "32" height = "32" } }
  173. identityMain {
  174. identityLabel { row.label }
  175. identityShortId { class = "shortid" row.shortId }
  176. if (row.isDefault) { defaultBadge { "default" } }
  177. identityDetails { row.details }
  178. }
  179. button { type = "button" class = "choose" value = row.id "Continue" on click(e) { emit choose(e.target.value) } }
  180. }
  181. }
  182. }
  183. }
  184. }
  185. if (manage) {
  186. section { id = "identitiessection"
  187. sectionHead {
  188. h1 { "Your identities" }
  189. button { id = "newidentity" type = "button" class = "small" "New identity" on click(e) { emit openNew(e) } }
  190. }
  191. ul { id = "identities"
  192. for (row of identities) {
  193. li { class = "identity"
  194. if (row.hasAvatar) { img { class = "avatar" src = row.avatar alt = "" width = "32" height = "32" } }
  195. identityMain {
  196. identityLabel { row.label }
  197. identityShortId { class = "shortid" row.shortId }
  198. if (row.isDefault) { defaultBadge { "default" } }
  199. identityDetails { row.details }
  200. }
  201. identityActions {
  202. button { type = "button" class = "quiet small edit" value = row.id "Edit" on click(e) { emit openEdit(e.target.value) } }
  203. if (row.canDelete) { button { type = "button" class = "quiet small danger delete" value = row.id "Delete" on click(e) { emit remove(e.target.value) } } }
  204. }
  205. }
  206. }
  207. }
  208. }
  209. section { id = "timezonesection"
  210. h2 { "Time zone" }
  211. p { class = "lead" "Taken from your browser at your first login. Currently " strong { id = "timezone" timeZone } "." }
  212. form { id = "tzform"
  213. on submit(e) {
  214. e.preventDefault()
  215. emit saveZone(e)
  216. }
  217. label { "Time zone (IANA name, e.g. Europe/Vienna)"
  218. input { id = "tzinput" name = "timeZone" autocomplete = "off" maxlength = "64" required = "required" value = tzInput on input(e) { emit setTz(e.target.value) } }
  219. }
  220. formButtons {
  221. button { id = "savetz" type = "submit" "Save time zone" }
  222. button { id = "browsertz" type = "button" class = "quiet" "Use this browser's" on click(e) { emit useBrowserZone(e) } }
  223. }
  224. }
  225. }
  226. }
  227. }
  228. p { id = "notice" class = "notice" notice }
  229. p { id = "message" class = "message" message }
  230. }
  231. }
  232. on setEmail(v) { email = v }
  233. on setCode(v) { code = v }
  234. on setTz(v) { tzInput = v }
  235. on setF(name, v) {
  236. if (name == 'identityName') { fIdentityName = v }
  237. if (name == 'nickname') { fNickname = v }
  238. if (name == 'firstname') { fFirstname = v }
  239. if (name == 'lastname') { fLastname = v }
  240. if (name == 'avatar') { fAvatar = v hasAvatarPreview = v.trim() != '' }
  241. }
  242. // the browser's own time zone (IANA name) and whether a name is one it knows (`Intl` is a
  243. // browser global of client code, hybriel#18)
  244. browserZone = () => { return Intl.DateTimeFormat().resolvedOptions().timeZone }
  245. knownZone = (v) => { return v == 'UTC' || Intl.supportedValuesOf('timeZone').includes(v) }
  246. // THE CODE REQUEST is a plain POST to /api/code (lib/api.hl), not a face: only an HTTP
  247. // request carries the client IP (X-Client-IP) the per-IP limit counts (mission 010).
  248. // A refusal (400, 429) answers { error } and is shown like every other message.
  249. on ask(e) {
  250. message = ''
  251. res = fetch('/api/code', { method = 'POST' headers = { 'Content-Type' = 'application/json' } body = JSON.stringify({ email = email }) })
  252. r = res != null ? res.json() : null
  253. if (r == null) {
  254. message = 'the server did not answer — try again'
  255. return null
  256. }
  257. if (r.error != null) {
  258. message = r.error
  259. return null
  260. }
  261. // THE SESSION REMEMBERS THE STEP, then the browser goes to the code page. A function
  262. // route gets no session (hybriel#11), so the face records it; it refuses unless a
  263. // code for the address is really waiting.
  264. k = emit server rememberPending(r.email)
  265. if (k == null) {
  266. message = 'the server did not answer — try again'
  267. return null
  268. }
  269. if (k.error != null) {
  270. message = k.error
  271. return null
  272. }
  273. window.location.assign(codeUrl)
  274. }
  275. on verify(e) {
  276. message = ''
  277. r = emit server verifyCode(sentTo, code, browserZone())
  278. if (r == null) {
  279. message = 'the server did not answer — try again'
  280. return null
  281. }
  282. if (r.error != null) {
  283. message = r.error
  284. return null
  285. }
  286. askCode = false
  287. // signed in: the address bar leaves the code page (a reload of /code would go back
  288. // to it anyway — lib/api.hl codePage sends a signed-in browser there)
  289. window.history.replaceState(null, '', homeUrl)
  290. meEmail = r.account.email
  291. timeZone = r.account.timeZone
  292. tzInput = r.account.timeZone
  293. identities = r.identities
  294. signedIn = true
  295. notice = ''
  296. manage = !forApp
  297. choosing = forApp
  298. // THE FIRST LOGIN: the default identity's names, optional (for an app too: the
  299. // choice follows once the form is saved or skipped)
  300. if (r.created) {
  301. fillForm(r.identities[0])
  302. editHeading = 'Welcome — name your default identity'
  303. welcome = true
  304. notWelcome = false
  305. editing = true
  306. choosing = false
  307. }
  308. }
  309. // THE CHOICE: the server makes (or finds) this identity's id for the app and answers
  310. // the app's return URL with a one-time code — the browser goes there
  311. on choose(id) {
  312. message = ''
  313. notice = ''
  314. r = emit server chooseIdentity(rid, '' + id)
  315. if (r == null) {
  316. message = 'the server did not answer — try again'
  317. return null
  318. }
  319. if (r.error != null) {
  320. message = r.error
  321. return null
  322. }
  323. notice = 'Back to ' + appName + ' …'
  324. window.location.assign(r.url)
  325. }
  326. fillForm = (row) => {
  327. editId = row.id
  328. fIdentityName = row.identityName
  329. fNickname = row.nickname
  330. fFirstname = row.firstname
  331. fLastname = row.lastname
  332. fAvatar = row.avatar
  333. hasAvatarPreview = row.hasAvatar
  334. return null
  335. }
  336. rowOf = (id) => {
  337. for (row of identities) { if ('' + row.id == '' + id) { return row } }
  338. return null
  339. }
  340. on openNew(e) {
  341. message = ''
  342. notice = ''
  343. editId = ''
  344. fIdentityName = ''
  345. fNickname = ''
  346. fFirstname = ''
  347. fLastname = ''
  348. fAvatar = ''
  349. hasAvatarPreview = false
  350. editHeading = 'New identity'
  351. welcome = false
  352. notWelcome = true
  353. editing = true
  354. }
  355. on openEdit(id) {
  356. message = ''
  357. notice = ''
  358. row = rowOf(id)
  359. if (row == null) { return null }
  360. fillForm(row)
  361. editHeading = 'Edit ' + row.label
  362. welcome = false
  363. notWelcome = true
  364. editing = true
  365. }
  366. on closeForm(e) {
  367. editing = false
  368. welcome = false
  369. notWelcome = true
  370. message = ''
  371. choosing = forApp
  372. }
  373. on saveForm(e) {
  374. message = ''
  375. fields = { identityName = fIdentityName nickname = fNickname firstname = fFirstname lastname = fLastname avatar = fAvatar }
  376. let r = null
  377. if (editId == '') {
  378. r = emit server addIdentity(fields)
  379. } else {
  380. r = emit server editIdentity('' + editId, fields)
  381. }
  382. if (r == null) {
  383. message = 'the server did not answer — try again'
  384. return null
  385. }
  386. if (r.error != null) {
  387. message = r.error
  388. return null
  389. }
  390. identities = r.identities
  391. notice = editId == '' ? 'Identity created.' : 'Identity saved.'
  392. editing = false
  393. welcome = false
  394. notWelcome = true
  395. choosing = forApp
  396. }
  397. on remove(id) {
  398. message = ''
  399. notice = ''
  400. row = rowOf(id)
  401. if (row == null) { return null }
  402. if (!confirm('Delete the identity “' + row.label + '”?')) { return null }
  403. r = emit server dropIdentity('' + id)
  404. if (r == null) {
  405. message = 'the server did not answer — try again'
  406. return null
  407. }
  408. if (r.error != null) {
  409. message = r.error
  410. return null
  411. }
  412. identities = r.identities
  413. if ('' + editId == '' + id) { editing = false }
  414. notice = 'Identity deleted.'
  415. }
  416. on useBrowserZone(e) { tzInput = browserZone() }
  417. on saveZone(e) {
  418. message = ''
  419. notice = ''
  420. v = tzInput.trim()
  421. if (!knownZone(v)) {
  422. message = 'this browser does not know the time zone “' + v + '”'
  423. return null
  424. }
  425. r = emit server changeTimeZone(v)
  426. if (r == null) {
  427. message = 'the server did not answer — try again'
  428. return null
  429. }
  430. if (r.error != null) {
  431. message = r.error
  432. return null
  433. }
  434. timeZone = r.timeZone
  435. tzInput = r.timeZone
  436. notice = 'Time zone saved.'
  437. }
  438. // "Other address": the session forgets the pending sign-in, back to the email form
  439. on back(e) {
  440. message = ''
  441. r = emit server forgetPending()
  442. window.location.assign(homeUrl)
  443. }
  444. on doSignOut(e) {
  445. emit server signOut()
  446. signedIn = false
  447. choosing = false
  448. manage = false
  449. editing = false
  450. welcome = false
  451. notWelcome = true
  452. meEmail = ''
  453. identities = []
  454. askEmail = true
  455. message = ''
  456. notice = ''
  457. }
  458. // SIGNS OUT ON EVERY DEVICE at once (bumps the account's session epoch, lib/accounts.hl
  459. // signOutEverywhere) — this browser included, so it resets to the sign-in form too.
  460. on doSignOutAll(e) {
  461. message = ''
  462. notice = ''
  463. if (!confirm('Sign out of ident on every device?')) { return null }
  464. r = emit server signOutAll()
  465. if (r == null) {
  466. message = 'the server did not answer — try again'
  467. return null
  468. }
  469. if (r.error != null) {
  470. message = r.error
  471. return null
  472. }
  473. signedIn = false
  474. choosing = false
  475. manage = false
  476. editing = false
  477. welcome = false
  478. notWelcome = true
  479. meEmail = ''
  480. identities = []
  481. askEmail = true
  482. notice = 'Signed out on every device.'
  483. }
  484. // ---- the faces ------------------------------------------------------------------------
  485. // A face's LAST parameter is the session (the framework appends it). Every face that
  486. // needs the account asks accountOfSession, which only accepts the framework's Session.
  487. // A face runs on a BLANK instance: it has its arguments and its imports, not this
  488. // component's members or functions.
  489. // (the code request is NOT a face since mission 010: POST /api/code in lib/api.hl — a face
  490. // sees no request headers, so it could not apply the per-IP limit; a face here would be a
  491. // way around it)
  492. // the right code signs the session in; the first one for an address creates the
  493. // account, its default identity and stores the browser's time zone
  494. on server verifyCode(email, code, timeZone, session) {
  495. if (session == null) { return { error = 'no session — reload the page' } }
  496. if (email == null || hlTypeName(email) != 'String' || code == null || hlTypeName(code) != 'String') { return { error = 'email and code must be strings' } }
  497. return signInWithCode(session, email, code, timeZone)
  498. }
  499. on server addIdentity(fields, session) {
  500. let me = accountOfSession(session)
  501. if (me == null) { return { error = 'you are not signed in' } }
  502. r = createIdentity(me.id, fields)
  503. if (r.error != null) { return r }
  504. return { identity = r.identity identities = identityRows(me.id) }
  505. }
  506. on server editIdentity(id, fields, session) {
  507. let me = accountOfSession(session)
  508. if (me == null) { return { error = 'you are not signed in' } }
  509. r = updateIdentity(me.id, id, fields)
  510. if (r.error != null) { return r }
  511. return { identity = r.identity identities = identityRows(me.id) }
  512. }
  513. on server dropIdentity(id, session) {
  514. let me = accountOfSession(session)
  515. if (me == null) { return { error = 'you are not signed in' } }
  516. r = deleteIdentity(me.id, id)
  517. if (r.error != null) { return r }
  518. return { deleted = r.deleted identities = identityRows(me.id) }
  519. }
  520. on server changeTimeZone(tz, session) {
  521. let me = accountOfSession(session)
  522. if (me == null) { return { error = 'you are not signed in' } }
  523. r = setTimeZone(me.id, tz)
  524. if (r.error != null) { return r }
  525. return { timeZone = r.account.timeZone }
  526. }
  527. // the identity for the app's login request (lib/apps.hl grantRequest: a plain login, or an invite's): { url } or { error }
  528. on server chooseIdentity(rid, identity, session) {
  529. let me = accountOfSession(session)
  530. if (me == null) { return { error = 'you are not signed in' } }
  531. if (rid == null || hlTypeName(rid) != 'String') { return { error = 'field rid must be a string' } }
  532. if (identity == null || hlTypeName(identity) != 'String') { return { error = 'field identity must be a string' } }
  533. return grantRequest(me.id, rid, identity)
  534. }
  535. // THE PENDING SIGN-IN (ident#20): recorded after POST /api/code answered, only while a
  536. // code for that address is really waiting (lib/login.hl recordPending); "Other address"
  537. // forgets it. Named apart from the store functions (faces dispatch by name, hybriel#36).
  538. on server rememberPending(email, session) {
  539. return recordPending(session, email)
  540. }
  541. on server forgetPending(session) {
  542. return dropPending(session)
  543. }
  544. on server signOut(session) {
  545. if (session != null) { session.user = null }
  546. return true
  547. }
  548. on server signOutAll(session) {
  549. let me = accountOfSession(session)
  550. if (me == null) { return { error = 'you are not signed in' } }
  551. // pushed BEFORE the sessions go: the audience reads each connection's session user
  552. emit client signedOutAll(me.id)
  553. return signOutEverywhere(me.id)
  554. }

Branches

Latest commits

  • 5fdbb6b2ident mission 009: report — scratch folder notemre
  • 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