gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit836f644f836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre836f644f/apps.hl

16.0 KB

  1. // 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"). Statics only, the server realm.
  3. //
  4. // appsTable pk @id index @owner, !apiKey
  5. // { owner (account @id), name, origins ('a b c': the origins it runs
  6. // on, space separated), apiKey, secretHash = sha256(secret), created, updated }
  7. // connectionsTable pk @id index !pair, @app
  8. // { pair = '<app @id>:<identity @id>', app, identity, created } — `created` is
  9. // when the identity registered in that app (the per-app page).
  10. // What an app holds is the identity's ONE SHORT ID (ident#23, store.hl
  11. // `shortId`), the same in every app. Rows made before ident#23 still carry
  12. // `appIdentity` (the old per-app id, 32 hex) until the app has migrated:
  13. // POST /api/migrate-ids answers old → short and, with finish, drops them.
  14. // requestsTable pk @id index !rid a login button press waiting for the choice:
  15. // { rid, app (app @id), returnUrl, expires, invite (invite @id, ident#22; absent for a plain login) }
  16. // grantsTable pk @id index !hash a one-time code on its way back to the app:
  17. // { hash = sha256(code), app, identity, expires }
  18. //
  19. // Any signed-in account registers apps. An app has a PUBLIC API key (`pk_…`, it may sit
  20. // in a page) and a SECRET (`sk_…`, the app's server only). The secret is shown ONCE, when
  21. // it is made (register / new secret); only its sha256 is stored.
  22. // Public app ids are the records' mpackdb UUIDs (`@id`), as for accounts and identities;
  23. // the files live in storage/mpackdb/ (store.hl `dir`).
  24. import { MPackDB } from 'hl:mpackdb'
  25. import { now } from 'hl:time'
  26. import { randomBytes, sha256 } from 'hl:crypto'
  27. import { dir, envNumber, countOf, first, merged, hasControl, ownIdentity, isId, oldestFirst, identitiesTable, identityByShortId } from './store.hl'
  28. static appsTable = new MPackDB(file = dir + '/apps.db', primaryKey = '@id', indexes = ['@owner' '!apiKey'])
  29. static connectionsTable = new MPackDB(file = dir + '/connections.db', primaryKey = '@id', indexes = ['!pair' '@app'])
  30. static requestsTable = new MPackDB(file = dir + '/requests.db', primaryKey = '@id', indexes = ['!rid'])
  31. static grantsTable = new MPackDB(file = dir + '/grants.db', primaryKey = '@id', indexes = ['!hash'])
  32. // INVITES (ident#22, invites.hl owns the rules; the table is here so deleting an app can drop its invites)
  33. static invitesTable = new MPackDB(file = dir + '/invites.db', primaryKey = '@id', indexes = ['@app' '!hash'])
  34. static grantTtl = envNumber('IDENT_GRANT_TTL_MS', 60000) // a one-time code: 60 s
  35. static requestTtl = 1800000 // a login button press: 30 min
  36. static maxOrigins = 10
  37. static maxName = 60
  38. // ---- validation -----------------------------------------------------------------------
  39. static isList = (v) => { return v != null && hlTypeName(v) == 'Hybrid' && v.length != null }
  40. static isObject = (v) => { return v != null && hlTypeName(v) == 'Hybrid' && v.length == null }
  41. static checkName = (name) => {
  42. if (name == null || hlTypeName(name) != 'String') { return { error = 'field name must be a string' field = 'name' } }
  43. let t = name.trim()
  44. if (t == '') { return { error = 'the app needs a name' field = 'name' } }
  45. if (t.length > maxName) { return { error = 'field name is longer than ' + maxName + ' characters' field = 'name' } }
  46. if (hasControl(t)) { return { error = 'field name contains a control character' field = 'name' } }
  47. return { name = t }
  48. }
  49. // AN ORIGIN is `http(s)://host[:port]`: lowercase, no path, no user, no query. One
  50. // trailing slash is forgiven. Answers { origin } or { error }.
  51. static hostChars = 'abcdefghijklmnopqrstuvwxyz0123456789.-[]:'
  52. static checkOrigin = (o) => {
  53. if (o == null || hlTypeName(o) != 'String') { return { error = 'an origin must be a string' } }
  54. let t = o.trim().toLowerCase()
  55. if (t.length > 0 && t[t.length - 1] == '/') { t = t.slice(0, t.length - 1) }
  56. if (t.length > 200) { return { error = 'the origin ' + t + ' is too long' } }
  57. let scheme = t.slice(0, 7) == 'http://' ? 'http://' : (t.slice(0, 8) == 'https://' ? 'https://' : null)
  58. if (scheme == null) { return { error = 'an origin starts with http:// or https:// (' + t + ')' } }
  59. let host = t.slice(scheme.length, t.length)
  60. if (host == '' || host[0] == '.' || host[0] == ':') { return { error = 'the origin ' + t + ' has no host' } }
  61. let i = 0
  62. while (i < host.length) {
  63. if (!hostChars.includes(host[i])) { return { error = 'an origin is only scheme://host[:port], no path (' + t + ')' } }
  64. i = i + 1
  65. }
  66. return { origin = t }
  67. }
  68. // the app form's input, STRICT: exactly { name, origins } — origins a list of 1-10
  69. // strings. Answers { error, field } or { name, origins (list, deduplicated) }.
  70. static appFields = ['name' 'origins']
  71. static checkAppInput = (input) => {
  72. if (!isObject(input)) { return { error = 'the app must be an object' field = '' } }
  73. for (k of input.keys()) {
  74. if (!appFields.includes(k)) { return { error = 'unknown field: ' + k field = k } }
  75. }
  76. for (k of appFields) {
  77. if (input[k] == null) { return { error = 'missing field: ' + k field = k } }
  78. }
  79. let n = checkName(input.name)
  80. if (n.error != null) { return n }
  81. if (!isList(input.origins)) { return { error = 'field origins must be a list of strings' field = 'origins' } }
  82. let out = []
  83. for (o of input.origins) {
  84. let c = checkOrigin(o)
  85. if (c.error != null) { return { error = c.error field = 'origins' } }
  86. if (!out.includes(c.origin)) { out.push(c.origin) }
  87. }
  88. if (out.length == 0) { return { error = 'give at least one origin the app runs on' field = 'origins' } }
  89. if (out.length > maxOrigins) { return { error = 'at most ' + maxOrigins + ' origins' field = 'origins' } }
  90. return { name = n.name origins = out }
  91. }
  92. // ---- apps -----------------------------------------------------------------------------
  93. static newKey = () => { return 'pk_' + randomBytes(16) } // 32 hex
  94. static newSecret = () => { return 'sk_' + randomBytes(24) } // 48 hex
  95. static originsOf = (rec) => { return rec.origins == '' ? [] : rec.origins.split(' ') }
  96. static appRowOf = (rec) => {
  97. if (rec == null) { return null }
  98. let origins = originsOf(rec)
  99. return { id = rec.id name = rec.name origins = origins originsText = origins.join(' ') apiKey = rec.apiKey }
  100. }
  101. // the account's apps, oldest first (stored `created`, never key order)
  102. static appRows = (accountId) => {
  103. let out = []
  104. if (!isId(accountId)) { return out }
  105. for (r of oldestFirst(appsTable.find('owner', accountId))) { out.push(appRowOf(r)) }
  106. return out
  107. }
  108. // the app record `id` (public) if the account owns it, else null
  109. static ownApp = (accountId, id) => {
  110. if (!isId(id)) { return null }
  111. let r = appsTable.fetch(id)
  112. if (r == null || r.owner != accountId) { return null }
  113. return r
  114. }
  115. static appByKey = (key) => {
  116. if (key == null || hlTypeName(key) != 'String' || key == '') { return null }
  117. return first(appsTable.find('apiKey', key))
  118. }
  119. // answers { error, field } or { app (row), secret } — the secret leaves ident only here
  120. static createApp = (accountId, input) => {
  121. let c = checkAppInput(input)
  122. if (c.error != null) { return c }
  123. let secret = newSecret()
  124. let id = appsTable.put({ owner = accountId name = c.name origins = c.origins.join(' ') apiKey = newKey() secretHash = sha256(secret) created = now() updated = now() })
  125. return { app = appRowOf(appsTable.fetch(id)) secret = secret }
  126. }
  127. static updateApp = (accountId, id, input) => {
  128. let r = ownApp(accountId, id)
  129. if (r == null) { return { error = 'no such app' field = 'id' } }
  130. let c = checkAppInput(input)
  131. if (c.error != null) { return c }
  132. appsTable.update(r.id, merged(r, { name = c.name origins = c.origins.join(' ') updated = now() }))
  133. return { app = appRowOf(appsTable.fetch(r.id)) }
  134. }
  135. // a NEW SECRET: the old one stops working at once; the API key stays
  136. static regenerateSecret = (accountId, id) => {
  137. let r = ownApp(accountId, id)
  138. if (r == null) { return { error = 'no such app' } }
  139. let secret = newSecret()
  140. appsTable.update(r.id, merged(r, { secretHash = sha256(secret) updated = now() }))
  141. return { app = appRowOf(appsTable.fetch(r.id)) secret = secret }
  142. }
  143. // DELETING AN APP removes its connections (the per-app ids) with it; a pending login
  144. // request or code for it dies because its app is gone
  145. static deleteApp = (accountId, id) => {
  146. let r = ownApp(accountId, id)
  147. if (r == null) { return { error = 'no such app' } }
  148. let conns = connectionsTable.find('app', id)
  149. if (countOf(conns) > 0) { for (c of conns) { connectionsTable.delete(c.id) } }
  150. let invs = invitesTable.find('app', id)
  151. if (countOf(invs) > 0) { for (v of invs) { invitesTable.delete(v.id) } }
  152. appsTable.delete(r.id)
  153. return { deleted = id }
  154. }
  155. // ---- the login button ---------------------------------------------------------------
  156. // A RETURN URL is absolute http(s), printable, no fragment, at most 2000 chars, and its
  157. // origin is one of the app's. Answers { error } or { origin }.
  158. static urlChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~:/?&=%+,;@!$()*[]'
  159. static checkReturn = (url, appRec) => {
  160. if (url == null || url == '') { return { error = 'the return parameter is missing' } }
  161. if (url.length > 2000) { return { error = 'the return URL is too long' } }
  162. let i = 0
  163. while (i < url.length) {
  164. if (!urlChars.includes(url[i])) { return { error = 'the return URL contains a character that is not allowed' } }
  165. i = i + 1
  166. }
  167. let lower = url.toLowerCase()
  168. let scheme = lower.slice(0, 7) == 'http://' ? 'http://' : (lower.slice(0, 8) == 'https://' ? 'https://' : null)
  169. if (scheme == null) { return { error = 'the return URL must start with http:// or https://' } }
  170. let rest = lower.slice(scheme.length, lower.length)
  171. let end = rest.length
  172. for (ch of ['/' '?']) {
  173. let at = rest.indexOf(ch)
  174. if (at >= 0 && at < end) { end = at }
  175. }
  176. let host = rest.slice(0, end)
  177. if (host == '') { return { error = 'the return URL has no host' } }
  178. let origin = scheme + host
  179. if (!originsOf(appRec).includes(origin)) { return { error = 'the return URL is on ' + origin + ', which is not one of the origins registered for ' + appRec.name } }
  180. return { origin = origin }
  181. }
  182. // the login button's GET /login?key=&return= : answers { error } or { rid }
  183. static openRequest = (key, returnUrl) => {
  184. let a = appByKey(key)
  185. if (a == null) { return { error = key == null || key == '' ? 'the key parameter (the app’s API key) is missing' : 'no app has this API key' } }
  186. let r = checkReturn(returnUrl, a)
  187. if (r.error != null) { return r }
  188. let rid = randomBytes(16)
  189. requestsTable.put({ rid = rid app = a.id returnUrl = returnUrl expires = now() + requestTtl })
  190. return { rid = rid }
  191. }
  192. // { rid, app (row), origin, returnUrl } or null (unknown, expired, or its app is gone)
  193. static requestOf = (rid) => {
  194. if (rid == null || hlTypeName(rid) != 'String' || rid == '') { return null }
  195. let r = first(requestsTable.find('rid', rid))
  196. if (r == null) { return null }
  197. if (r.expires < now()) {
  198. requestsTable.delete(r.id)
  199. return null
  200. }
  201. let a = isId(r.app) ? appsTable.fetch(r.app) : null
  202. if (a == null) { return null }
  203. let c = checkReturn(r.returnUrl, a)
  204. if (c.error != null) { return null } // the origin was removed from the app since
  205. return { rid = r.rid app = appRowOf(a) origin = c.origin returnUrl = r.returnUrl invite = r.invite == null ? '' : r.invite }
  206. }
  207. // The identity's connection to the app, made on the first login
  208. static connectionOf = (appId, identityId) => {
  209. let pair = appId + ':' + identityId
  210. let c = first(connectionsTable.find('pair', pair))
  211. if (c != null) { return c }
  212. let id = connectionsTable.put({ pair = pair app = appId identity = identityId created = now() })
  213. return connectionsTable.fetch(id)
  214. }
  215. // "SIGN OUT" OF AN APP (ticket #2): forgets this identity's connection to it (its
  216. // notification settings for the app). The identity's short id does not change: a later
  217. // login makes a fresh connection and the app gets the same id. Notifications already sent
  218. // through the old connection stay (as for a deleted app).
  219. static disconnectConnection = (accountId, id) => {
  220. if (!isId(id)) { return { error = 'no such app connection' } }
  221. let c = connectionsTable.fetch(id)
  222. if (c == null) { return { error = 'no such app connection' } }
  223. let ident = ownIdentity(accountId, c.identity)
  224. if (ident == null) { return { error = 'no such app connection' } }
  225. connectionsTable.delete(c.id)
  226. return { disconnected = id }
  227. }
  228. // A ONE-TIME CODE for app `appId` and identity `identityId` (public ids): 48 hex, single
  229. // use, grantTtl (60 s), this app only. The login button AND the selector (selector.hl)
  230. // hand out these codes; the app's server trades one with `exchange` below.
  231. static issueCode = (appId, identityId) => {
  232. connectionOf(appId, identityId)
  233. let code = randomBytes(24)
  234. grantsTable.put({ hash = sha256(code) app = appId identity = identityId expires = now() + grantTtl })
  235. return code
  236. }
  237. // THE CHOICE: the account picks one of its identities for the request. Answers { error }
  238. // or { url } — the app's return URL with a fresh one-time `ident_code`.
  239. static grantLogin = (accountId, rid, identityId) => {
  240. let rq = requestOf(rid)
  241. if (rq == null) { return { error = 'this login request is unknown or expired — go back to the app and start again' } }
  242. let ident = ownIdentity(accountId, identityId)
  243. if (ident == null) { return { error = 'no such identity' } }
  244. let code = issueCode(rq.app.id, identityId)
  245. let r = first(requestsTable.find('rid', rid))
  246. if (r != null) { requestsTable.delete(r.id) }
  247. let sep = rq.returnUrl.includes('?') ? '&' : '?'
  248. return { url = rq.returnUrl + sep + 'ident_code=' + code }
  249. }
  250. // THE EXCHANGE (the app's server): answers { status, error } or { identity }. The app
  251. // authenticates with key + secret (401); a code works once, for its own app, for 60 s (400).
  252. // A code shown to the wrong app is spent: it has leaked.
  253. static exchange = (key, secret, code) => {
  254. let a = appByKey(key)
  255. if (a == null || sha256(secret) != a.secretHash) { return { status = 401 error = 'unknown API key or wrong secret' } }
  256. let g = first(grantsTable.find('hash', sha256(code)))
  257. if (g == null) { return { status = 400 error = 'unknown or already used code' } }
  258. grantsTable.delete(g.id)
  259. if (g.app != a.id) { return { status = 400 error = 'this code was not issued to this app' } }
  260. if (g.expires < now()) { return { status = 400 error = 'the code expired' } }
  261. let c = first(connectionsTable.find('pair', a.id + ':' + g.identity))
  262. let ident = isId(g.identity) ? identitiesTable.fetch(g.identity) : null
  263. if (c == null || ident == null) { return { status = 400 error = 'the identity is no longer connected to this app' } }
  264. return { identity = ident.shortId }
  265. }
  266. // WHAT THE APP MEANT BY AN ID (ident#23): a notification or a lookup may name an identity by
  267. // its short id, or — until the app has migrated — by its old per-app id. Answers the
  268. // connection (of app `appId`) or null.
  269. static connectionByAnyId = (appId, given) => {
  270. let ident = identityByShortId(given)
  271. if (ident != null) { return first(connectionsTable.find('pair', appId + ':' + ident.id)) }
  272. if (given == null || hlTypeName(given) != 'String') { return null }
  273. let rows = connectionsTable.find('app', appId)
  274. if (countOf(rows) > 0) {
  275. for (c of rows) { if (c.appIdentity != null && c.appIdentity == given) { return c } }
  276. }
  277. return null
  278. }
  279. // THE MIGRATION (ident#23), for the app's server: answers { ids } — every old per-app id
  280. // → the identity's short id (an identity that was deleted has none and is left out).
  281. // With `finish` the old ids are dropped afterwards: the app has stored the short ids.
  282. static migrateIds = (a, finish) => {
  283. let ids = {}
  284. let n = 0
  285. let rows = connectionsTable.find('app', a.id)
  286. if (countOf(rows) > 0) {
  287. for (c of rows) {
  288. if (c.appIdentity == null) { continue }
  289. let ident = isId(c.identity) ? identitiesTable.fetch(c.identity) : null
  290. if (ident != null) { ids[c.appIdentity] = ident.shortId n = n + 1 }
  291. if (finish) {
  292. let rec = {}
  293. for (k of c.keys()) { if (k != 'appIdentity') { rec[k] = c[k] } }
  294. connectionsTable.update(c.id, rec)
  295. }
  296. }
  297. }
  298. return { ids = ids count = n finished = finish }
  299. }

Branches

Latest commits

  • 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