gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitff78726cff78726cident: Hybriel master 190aa11d (fc838894 GC correctness, #127, #126 closure scopes); gates all greenmreff78726c/components/home.hl

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

Branches

Latest commits

  • 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