gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Main branchmain5fdbb6b2ident mission 009: report — scratch folder notemremain/lib/apps-helpers.hl

4.9 KB

  1. // lib/apps-helpers.hl — what lib/apps.hl (and lib/invites.hl) look up: the app form's strict input, an origin,
  2. // a return URL against the app's origins, new keys and secrets, the row a page shows. No table access. Statics only.
  3. import { randomBytes } from 'hl:crypto'
  4. import { hasControl, isList, isObject, urlChars } from './util.hl'
  5. static maxOrigins = 10
  6. static maxName = 60
  7. // ---- validation -----------------------------------------------------------------------
  8. static checkName = (name) => {
  9. if (name == null || hlTypeName(name) != 'String') { return { error = 'field name must be a string' field = 'name' } }
  10. t = name.trim()
  11. if (t == '') { return { error = 'the app needs a name' field = 'name' } }
  12. if (t.length > maxName) { return { error = 'field name is longer than ' + maxName + ' characters' field = 'name' } }
  13. if (hasControl(t)) { return { error = 'field name contains a control character' field = 'name' } }
  14. return { name = t }
  15. }
  16. // AN ORIGIN is `http(s)://host[:port]`: lowercase, no path, no user, no query. One
  17. // trailing slash is forgiven. Answers { origin } or { error }.
  18. static hostChars = 'abcdefghijklmnopqrstuvwxyz0123456789.-[]:'
  19. static checkOrigin = (o) => {
  20. if (o == null || hlTypeName(o) != 'String') { return { error = 'an origin must be a string' } }
  21. let t = o.trim().toLowerCase()
  22. if (t.length > 0 && t[t.length - 1] == '/') { t = t.slice(0, t.length - 1) }
  23. if (t.length > 200) { return { error = 'the origin ' + t + ' is too long' } }
  24. scheme = t.slice(0, 7) == 'http://' ? 'http://' : (t.slice(0, 8) == 'https://' ? 'https://' : null)
  25. if (scheme == null) { return { error = 'an origin starts with http:// or https:// (' + t + ')' } }
  26. host = t.slice(scheme.length, t.length)
  27. if (host == '' || host[0] == '.' || host[0] == ':') { return { error = 'the origin ' + t + ' has no host' } }
  28. let i = 0
  29. while (i < host.length) {
  30. if (!hostChars.includes(host[i])) { return { error = 'an origin is only scheme://host[:port], no path (' + t + ')' } }
  31. i = i + 1
  32. }
  33. return { origin = t }
  34. }
  35. // the app form's input, STRICT: exactly { name, origins } — origins a list of 1-10
  36. // strings. Answers { error, field } or { name, origins (list, deduplicated) }.
  37. static appFields = ['name' 'origins']
  38. static checkAppInput = (input) => {
  39. if (!isObject(input)) { return { error = 'the app must be an object' field = '' } }
  40. for (k of input.keys()) {
  41. if (!appFields.includes(k)) { return { error = 'unknown field: ' + k field = k } }
  42. }
  43. for (k of appFields) {
  44. if (input[k] == null) { return { error = 'missing field: ' + k field = k } }
  45. }
  46. n = checkName(input.name)
  47. if (n.error != null) { return n }
  48. if (!isList(input.origins)) { return { error = 'field origins must be a list of strings' field = 'origins' } }
  49. out = []
  50. for (o of input.origins) {
  51. let c = checkOrigin(o)
  52. if (c.error != null) { return { error = c.error field = 'origins' } }
  53. if (!out.includes(c.origin)) { out.push(c.origin) }
  54. }
  55. if (out.length == 0) { return { error = 'give at least one origin the app runs on' field = 'origins' } }
  56. if (out.length > maxOrigins) { return { error = 'at most ' + maxOrigins + ' origins' field = 'origins' } }
  57. return { name = n.name origins = out }
  58. }
  59. // ---- apps -----------------------------------------------------------------------------
  60. static newKey = () => { return 'pk_' + randomBytes(16) } // 32 hex
  61. static newSecret = () => { return 'sk_' + randomBytes(24) } // 48 hex
  62. static originsOf = (rec) => { return rec.origins == '' ? [] : rec.origins.split(' ') }
  63. static appRowOf = (rec) => {
  64. if (rec == null) { return null }
  65. let origins = originsOf(rec)
  66. return { id = rec.id name = rec.name origins = origins originsText = origins.join(' ') apiKey = rec.apiKey }
  67. }
  68. // ---- the login button ---------------------------------------------------------------
  69. // A RETURN URL is absolute http(s), printable, no fragment, at most 2000 chars, and its
  70. // origin is one of the app's. Answers { error } or { origin }.
  71. static checkReturn = (url, appRec) => {
  72. if (url == null || url == '') { return { error = 'the return parameter is missing' } }
  73. if (url.length > 2000) { return { error = 'the return URL is too long' } }
  74. let i = 0
  75. while (i < url.length) {
  76. if (!urlChars.includes(url[i])) { return { error = 'the return URL contains a character that is not allowed' } }
  77. i = i + 1
  78. }
  79. lower = url.toLowerCase()
  80. scheme = lower.slice(0, 7) == 'http://' ? 'http://' : (lower.slice(0, 8) == 'https://' ? 'https://' : null)
  81. if (scheme == null) { return { error = 'the return URL must start with http:// or https://' } }
  82. rest = lower.slice(scheme.length, lower.length)
  83. let end = rest.length
  84. for (ch of ['/' '?']) {
  85. let at = rest.indexOf(ch)
  86. if (at >= 0 && at < end) { end = at }
  87. }
  88. host = rest.slice(0, end)
  89. if (host == '') { return { error = 'the return URL has no host' } }
  90. let origin = scheme + host
  91. if (!originsOf(appRec).includes(origin)) { return { error = 'the return URL is on ' + origin + ', which is not one of the origins registered for ' + appRec.name } }
  92. return { origin = origin }
  93. }

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