gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitfe183516fe183516ident 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 outputmrefe183516/lib/apps.hl

13.5 KB

  1. // lib/apps.hl — APPS AND THE LOGIN BUTTON (piece 2 of 6, ticket #25; CONCEPT.md "Apps",
  2. // "One id per app", "Flow 1: login button"; the invite's login flow, ident#22). Statics only, the server realm.
  3. // Every write to the apps, connections, requests and grants tables is here. The checks (app form, origins,
  4. // return URL), keys and the row a page shows: lib/apps-helpers.hl.
  5. //
  6. // appsTable pk @id index @owner, !apiKey
  7. // { owner (account @id), name, origins ('a b c': the origins it runs
  8. // on, space separated), apiKey, secretHash = sha256(secret), created, updated }
  9. // connectionsTable pk @id index !pair, @app
  10. // { pair = '<app @id>:<identity @id>', app, identity, created } — `created` is
  11. // when the identity registered in that app (the per-app page).
  12. // What an app holds is the identity's ONE SHORT ID (ident#23, lib/identities.hl
  13. // `shortId`), the same in every app. Rows made before ident#23 still carry
  14. // `appIdentity` (the old per-app id, 32 hex) until the app has migrated:
  15. // POST /api/migrate-ids answers old → short and, with finish, drops them.
  16. // requestsTable pk @id index !rid a login button press waiting for the choice:
  17. // { rid, app (app @id), returnUrl, expires, invite (invite @id, ident#22; absent for a plain login) }
  18. // grantsTable pk @id index !hash a one-time code on its way back to the app:
  19. // { hash = sha256(code), app, identity, expires }
  20. //
  21. // Any signed-in account registers apps. An app has a PUBLIC API key (`pk_…`, it may sit
  22. // in a page) and a SECRET (`sk_…`, the app's server only). The secret is shown ONCE, when
  23. // it is made (register / new secret); only its sha256 is stored.
  24. // Public app ids are the records' mpackdb UUIDs (`@id`), as for accounts and identities;
  25. // the files live in storage/mpackdb/ (lib/util.hl `dir`).
  26. import { MPackDB } from 'hl:mpackdb'
  27. import { now } from 'hl:time'
  28. import { randomBytes, sha256 } from 'hl:crypto'
  29. import { dir, envNumber, countOf, first, merged, isId, oldestFirst } from './util.hl'
  30. import { checkAppInput, newKey, newSecret, appRowOf, checkReturn } from './apps-helpers.hl'
  31. import { ownIdentity, identitiesTable, identityByShortId } from './identities.hl'
  32. import { dropInvitesOf, openableInvite, inviteById, acceptInvite } from './invites.hl'
  33. static appsTable = new MPackDB(file = dir + '/apps.db', primaryKey = '@id', indexes = ['@owner' '!apiKey'])
  34. static connectionsTable = new MPackDB(file = dir + '/connections.db', primaryKey = '@id', indexes = ['!pair' '@app'])
  35. static requestsTable = new MPackDB(file = dir + '/requests.db', primaryKey = '@id', indexes = ['!rid'])
  36. static grantsTable = new MPackDB(file = dir + '/grants.db', primaryKey = '@id', indexes = ['!hash'])
  37. static grantTtl = envNumber('IDENT_GRANT_TTL_MS', 60000) // a one-time code: 60 s
  38. static requestTtl = 1800000 // a login button press: 30 min
  39. // ---- apps -----------------------------------------------------------------------------
  40. // the account's apps, oldest first (stored `created`, never key order)
  41. static appRows = (accountId) => {
  42. out = []
  43. if (!isId(accountId)) { return out }
  44. for (r of oldestFirst(appsTable.find('owner', accountId))) { out.push(appRowOf(r)) }
  45. return out
  46. }
  47. // the app record `id` (public) if the account owns it, else null
  48. static ownApp = (accountId, id) => {
  49. if (!isId(id)) { return null }
  50. r = appsTable.fetch(id)
  51. if (r == null || r.owner != accountId) { return null }
  52. return r
  53. }
  54. static appByKey = (key) => {
  55. if (key == null || hlTypeName(key) != 'String' || key == '') { return null }
  56. return first(appsTable.find('apiKey', key))
  57. }
  58. // THE APP'S SERVER, key + secret: answers the app record or null (unknown key or wrong secret: one answer, 401)
  59. static appOfSecret = (key, secret) => {
  60. a = appByKey(key)
  61. if (a == null || hlTypeName(secret) != 'String' || sha256(secret) != a.secretHash) { return null }
  62. return a
  63. }
  64. // answers { error, field } or { app (row), secret } — the secret leaves ident only here
  65. static createApp = (accountId, input) => {
  66. c = checkAppInput(input)
  67. if (c.error != null) { return c }
  68. let secret = newSecret()
  69. id = appsTable.put({ owner = accountId name = c.name origins = c.origins.join(' ') apiKey = newKey() secretHash = sha256(secret) created = now() updated = now() })
  70. return { app = appRowOf(appsTable.fetch(id)) secret = secret }
  71. }
  72. static updateApp = (accountId, id, input) => {
  73. r = ownApp(accountId, id)
  74. if (r == null) { return { error = 'no such app' field = 'id' } }
  75. c = checkAppInput(input)
  76. if (c.error != null) { return c }
  77. appsTable.update(r.id, merged(r, { name = c.name origins = c.origins.join(' ') updated = now() }))
  78. return { app = appRowOf(appsTable.fetch(r.id)) }
  79. }
  80. // a NEW SECRET: the old one stops working at once; the API key stays
  81. static regenerateSecret = (accountId, id) => {
  82. r = ownApp(accountId, id)
  83. if (r == null) { return { error = 'no such app' } }
  84. let secret = newSecret()
  85. appsTable.update(r.id, merged(r, { secretHash = sha256(secret) updated = now() }))
  86. return { app = appRowOf(appsTable.fetch(r.id)) secret = secret }
  87. }
  88. // DELETING AN APP removes its connections (the per-app ids) and its invites with it; a pending login
  89. // request or code for it dies because its app is gone
  90. static deleteApp = (accountId, id) => {
  91. r = ownApp(accountId, id)
  92. if (r == null) { return { error = 'no such app' } }
  93. conns = connectionsTable.find('app', id)
  94. if (countOf(conns) > 0) { for (c of conns) { connectionsTable.delete(c.id) } }
  95. dropInvitesOf(id)
  96. appsTable.delete(r.id)
  97. return { deleted = id }
  98. }
  99. // ---- the login button ---------------------------------------------------------------
  100. // the login button's GET /login?key=&return= : answers { error } or { rid }
  101. static openRequest = (key, returnUrl) => {
  102. a = appByKey(key)
  103. if (a == null) { return { error = key == null || key == '' ? 'the key parameter (the app’s API key) is missing' : 'no app has this API key' } }
  104. r = checkReturn(returnUrl, a)
  105. if (r.error != null) { return r }
  106. let rid = randomBytes(16)
  107. requestsTable.put({ rid = rid app = a.id returnUrl = returnUrl expires = now() + requestTtl })
  108. return { rid = rid }
  109. }
  110. // { rid, app (row), origin, returnUrl } or null (unknown, expired, or its app is gone)
  111. static requestOf = (rid) => {
  112. if (rid == null || hlTypeName(rid) != 'String' || rid == '') { return null }
  113. r = first(requestsTable.find('rid', rid))
  114. if (r == null) { return null }
  115. if (r.expires < now()) {
  116. requestsTable.delete(r.id)
  117. return null
  118. }
  119. a = isId(r.app) ? appsTable.fetch(r.app) : null
  120. if (a == null) { return null }
  121. c = checkReturn(r.returnUrl, a)
  122. if (c.error != null) { return null } // the origin was removed from the app since
  123. return { rid = r.rid app = appRowOf(a) origin = c.origin returnUrl = r.returnUrl invite = r.invite == null ? '' : r.invite }
  124. }
  125. // The identity's connection to the app, made on the first login
  126. static connectionOf = (appId, identityId) => {
  127. let pair = appId + ':' + identityId
  128. c = first(connectionsTable.find('pair', pair))
  129. if (c != null) { return c }
  130. id = connectionsTable.put({ pair = pair app = appId identity = identityId created = now() })
  131. return connectionsTable.fetch(id)
  132. }
  133. // "SIGN OUT" OF AN APP (ticket #2): forgets this identity's connection to it (its
  134. // notification settings for the app). The identity's short id does not change: a later
  135. // login makes a fresh connection and the app gets the same id. Notifications already sent
  136. // through the old connection stay (as for a deleted app).
  137. static disconnectConnection = (accountId, id) => {
  138. if (!isId(id)) { return { error = 'no such app connection' } }
  139. c = connectionsTable.fetch(id)
  140. if (c == null) { return { error = 'no such app connection' } }
  141. ident = ownIdentity(accountId, c.identity)
  142. if (ident == null) { return { error = 'no such app connection' } }
  143. connectionsTable.delete(c.id)
  144. return { disconnected = id }
  145. }
  146. // A ONE-TIME CODE for app `appId` and identity `identityId` (public ids): 48 hex, single
  147. // use, grantTtl (60 s), this app only. The login button AND the selector (lib/selector.hl)
  148. // hand out these codes; the app's server trades one with `exchange` below.
  149. static issueCode = (appId, identityId) => {
  150. connectionOf(appId, identityId)
  151. code = randomBytes(24)
  152. grantsTable.put({ hash = sha256(code) app = appId identity = identityId expires = now() + grantTtl })
  153. return code
  154. }
  155. // THE CHOICE: the account picks one of its identities for the request. Answers { error }
  156. // or { url } — the app's return URL with a fresh one-time `ident_code`.
  157. static grantLogin = (accountId, rid, identityId) => {
  158. rq = requestOf(rid)
  159. if (rq == null) { return { error = 'this login request is unknown or expired — go back to the app and start again' } }
  160. ident = ownIdentity(accountId, identityId)
  161. if (ident == null) { return { error = 'no such identity' } }
  162. code = issueCode(rq.app.id, identityId)
  163. r = first(requestsTable.find('rid', rid))
  164. if (r != null) { requestsTable.delete(r.id) }
  165. sep = rq.returnUrl.includes('?') ? '&' : '?'
  166. return { url = rq.returnUrl + sep + 'ident_code=' + code }
  167. }
  168. // THE EXCHANGE (the app's server): answers { status, error } or { identity }. The app
  169. // authenticates with key + secret (401); a code works once, for its own app, for 60 s (400).
  170. // A code shown to the wrong app is spent: it has leaked.
  171. static exchange = (key, secret, code) => {
  172. a = appByKey(key)
  173. if (a == null || sha256(secret) != a.secretHash) { return { status = 401 error = 'unknown API key or wrong secret' } }
  174. g = first(grantsTable.find('hash', sha256(code)))
  175. if (g == null) { return { status = 400 error = 'unknown or already used code' } }
  176. grantsTable.delete(g.id)
  177. if (g.app != a.id) { return { status = 400 error = 'this code was not issued to this app' } }
  178. if (g.expires < now()) { return { status = 400 error = 'the code expired' } }
  179. c = first(connectionsTable.find('pair', a.id + ':' + g.identity))
  180. ident = isId(g.identity) ? identitiesTable.fetch(g.identity) : null
  181. if (c == null || ident == null) { return { status = 400 error = 'the identity is no longer connected to this app' } }
  182. return { identity = ident.shortId }
  183. }
  184. // ---- AN INVITE'S LOGIN (ident#22; the invite records: lib/invites.hl) -------------------------------
  185. // GET /invite/<token>: answers { status, title, error } (an error page) or { rid } — a login
  186. // request of the app that carries the invite, so the login flow does the rest.
  187. static openInvite = (token) => {
  188. o = openableInvite(token)
  189. if (o.error != null) { return o }
  190. rec = o.invite
  191. let rid = randomBytes(16)
  192. requestsTable.put({ rid = rid app = rec.app returnUrl = rec.returnUrl invite = rec.id expires = now() + requestTtl })
  193. return { rid = rid }
  194. }
  195. // THE CHOICE for an invite's request: the identity accepts it. Answers { error } or { url }
  196. // (the app's return URL with ident_code and invite). An identity that already accepted this
  197. // invite may pass again without taking another use.
  198. static grantInvite = (accountId, rid, identityId) => {
  199. rq = requestOf(rid)
  200. if (rq == null) { return { error = 'this login request is unknown or expired — open the invitation link again' } }
  201. rec = inviteById(rq.invite)
  202. if (rec == null || rec.app != rq.app.id) { return { error = 'this invitation no longer exists' } }
  203. if (ownIdentity(accountId, identityId) == null) { return { error = 'no such identity' } }
  204. conn = connectionOf(rq.app.id, identityId)
  205. a = acceptInvite(rec, identityId, conn)
  206. if (a.error != null) { return a }
  207. code = issueCode(rq.app.id, identityId)
  208. r = first(requestsTable.find('rid', rid))
  209. if (r != null) { requestsTable.delete(r.id) }
  210. sep = rq.returnUrl.includes('?') ? '&' : '?'
  211. return { url = rq.returnUrl + sep + 'ident_code=' + code + '&invite=' + rec.id }
  212. }
  213. // the face chooseIdentity (components/home.hl): a request that carries an invite accepts the invite, any
  214. // other is a plain login. Answers { error } or { url }.
  215. static grantRequest = (accountId, rid, identityId) => {
  216. rq = requestOf(rid)
  217. if (rq != null && rq.invite != '') { return grantInvite(accountId, rid, identityId) }
  218. return grantLogin(accountId, rid, identityId)
  219. }
  220. // ---- THE SHORT IDS (ident#23) ---------------------------------------------------------------------
  221. // WHAT THE APP MEANT BY AN ID: a notification or a lookup may name an identity by
  222. // its short id, or — until the app has migrated — by its old per-app id. Answers the
  223. // connection (of app `appId`) or null.
  224. static connectionByAnyId = (appId, given) => {
  225. ident = identityByShortId(given)
  226. if (ident != null) { return first(connectionsTable.find('pair', appId + ':' + ident.id)) }
  227. if (given == null || hlTypeName(given) != 'String') { return null }
  228. rows = connectionsTable.find('app', appId)
  229. if (countOf(rows) > 0) {
  230. for (c of rows) { if (c.appIdentity != null && c.appIdentity == given) { return c } }
  231. }
  232. return null
  233. }
  234. // THE MIGRATION, for the app's server: answers { ids } — every old per-app id
  235. // → the identity's short id (an identity that was deleted has none and is left out).
  236. // With `finish` the old ids are dropped afterwards: the app has stored the short ids.
  237. static migrateIds = (a, finish) => {
  238. let ids = {}
  239. let n = 0
  240. rows = connectionsTable.find('app', a.id)
  241. if (countOf(rows) > 0) {
  242. for (c of rows) {
  243. if (c.appIdentity == null) { continue }
  244. let ident = isId(c.identity) ? identitiesTable.fetch(c.identity) : null
  245. if (ident != null) { ids[c.appIdentity] = ident.shortId n = n + 1 }
  246. if (finish) {
  247. let rec = {}
  248. for (k of c.keys()) { if (k != 'appIdentity') { rec[k] = c[k] } }
  249. connectionsTable.update(c.id, rec)
  250. }
  251. }
  252. }
  253. return { ids = ids count = n finished = finish }
  254. }

Branches

Latest commits

  • 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