ident
All repositories: gitoria
13.5 KB
// lib/apps.hl — APPS AND THE LOGIN BUTTON (piece 2 of 6, ticket #25; CONCEPT.md "Apps",// "One id per app", "Flow 1: login button"; the invite's login flow, ident#22). Statics only, the server realm.// Every write to the apps, connections, requests and grants tables is here. The checks (app form, origins,// return URL), keys and the row a page shows: lib/apps-helpers.hl.//// appsTable pk @id index @owner, !apiKey// { owner (account @id), name, origins ('a b c': the origins it runs// on, space separated), apiKey, secretHash = sha256(secret), created, updated }// connectionsTable pk @id index !pair, @app// { pair = '<app @id>:<identity @id>', app, identity, created } — `created` is// when the identity registered in that app (the per-app page).// What an app holds is the identity's ONE SHORT ID (ident#23, lib/identities.hl// `shortId`), the same in every app. Rows made before ident#23 still carry// `appIdentity` (the old per-app id, 32 hex) until the app has migrated:// POST /api/migrate-ids answers old → short and, with finish, drops them.// requestsTable pk @id index !rid a login button press waiting for the choice:// { rid, app (app @id), returnUrl, expires, invite (invite @id, ident#22; absent for a plain login) }// grantsTable pk @id index !hash a one-time code on its way back to the app:// { hash = sha256(code), app, identity, expires }//// Any signed-in account registers apps. An app has a PUBLIC API key (`pk_…`, it may sit// in a page) and a SECRET (`sk_…`, the app's server only). The secret is shown ONCE, when// it is made (register / new secret); only its sha256 is stored.// Public app ids are the records' mpackdb UUIDs (`@id`), as for accounts and identities;// the files live in storage/mpackdb/ (lib/util.hl `dir`).import { MPackDB } from 'hl:mpackdb'import { now } from 'hl:time'import { randomBytes, sha256 } from 'hl:crypto'import { dir, envNumber, countOf, first, merged, isId, oldestFirst } from './util.hl'import { checkAppInput, newKey, newSecret, appRowOf, checkReturn } from './apps-helpers.hl'import { ownIdentity, identitiesTable, identityByShortId } from './identities.hl'import { dropInvitesOf, openableInvite, inviteById, acceptInvite } from './invites.hl'static appsTable = new MPackDB(file = dir + '/apps.db', primaryKey = '@id', indexes = ['@owner' '!apiKey'])static connectionsTable = new MPackDB(file = dir + '/connections.db', primaryKey = '@id', indexes = ['!pair' '@app'])static requestsTable = new MPackDB(file = dir + '/requests.db', primaryKey = '@id', indexes = ['!rid'])static grantsTable = new MPackDB(file = dir + '/grants.db', primaryKey = '@id', indexes = ['!hash'])static grantTtl = envNumber('IDENT_GRANT_TTL_MS', 60000) // a one-time code: 60 sstatic requestTtl = 1800000 // a login button press: 30 min// ---- apps -----------------------------------------------------------------------------// the account's apps, oldest first (stored `created`, never key order)static appRows = (accountId) => {out = []if (!isId(accountId)) { return out }for (r of oldestFirst(appsTable.find('owner', accountId))) { out.push(appRowOf(r)) }return out}// the app record `id` (public) if the account owns it, else nullstatic ownApp = (accountId, id) => {if (!isId(id)) { return null }r = appsTable.fetch(id)if (r == null || r.owner != accountId) { return null }return r}static appByKey = (key) => {if (key == null || hlTypeName(key) != 'String' || key == '') { return null }return first(appsTable.find('apiKey', key))}// THE APP'S SERVER, key + secret: answers the app record or null (unknown key or wrong secret: one answer, 401)static appOfSecret = (key, secret) => {a = appByKey(key)if (a == null || hlTypeName(secret) != 'String' || sha256(secret) != a.secretHash) { return null }return a}// answers { error, field } or { app (row), secret } — the secret leaves ident only herestatic createApp = (accountId, input) => {c = checkAppInput(input)if (c.error != null) { return c }let secret = newSecret()id = appsTable.put({ owner = accountId name = c.name origins = c.origins.join(' ') apiKey = newKey() secretHash = sha256(secret) created = now() updated = now() })return { app = appRowOf(appsTable.fetch(id)) secret = secret }}static updateApp = (accountId, id, input) => {r = ownApp(accountId, id)if (r == null) { return { error = 'no such app' field = 'id' } }c = checkAppInput(input)if (c.error != null) { return c }appsTable.update(r.id, merged(r, { name = c.name origins = c.origins.join(' ') updated = now() }))return { app = appRowOf(appsTable.fetch(r.id)) }}// a NEW SECRET: the old one stops working at once; the API key staysstatic regenerateSecret = (accountId, id) => {r = ownApp(accountId, id)if (r == null) { return { error = 'no such app' } }let secret = newSecret()appsTable.update(r.id, merged(r, { secretHash = sha256(secret) updated = now() }))return { app = appRowOf(appsTable.fetch(r.id)) secret = secret }}// DELETING AN APP removes its connections (the per-app ids) and its invites with it; a pending login// request or code for it dies because its app is gonestatic deleteApp = (accountId, id) => {r = ownApp(accountId, id)if (r == null) { return { error = 'no such app' } }conns = connectionsTable.find('app', id)if (countOf(conns) > 0) { for (c of conns) { connectionsTable.delete(c.id) } }dropInvitesOf(id)appsTable.delete(r.id)return { deleted = id }}// ---- the login button ---------------------------------------------------------------// the login button's GET /login?key=&return= : answers { error } or { rid }static openRequest = (key, returnUrl) => {a = appByKey(key)if (a == null) { return { error = key == null || key == '' ? 'the key parameter (the app’s API key) is missing' : 'no app has this API key' } }r = checkReturn(returnUrl, a)if (r.error != null) { return r }let rid = randomBytes(16)requestsTable.put({ rid = rid app = a.id returnUrl = returnUrl expires = now() + requestTtl })return { rid = rid }}// { rid, app (row), origin, returnUrl } or null (unknown, expired, or its app is gone)static requestOf = (rid) => {if (rid == null || hlTypeName(rid) != 'String' || rid == '') { return null }r = first(requestsTable.find('rid', rid))if (r == null) { return null }if (r.expires < now()) {requestsTable.delete(r.id)return null}a = isId(r.app) ? appsTable.fetch(r.app) : nullif (a == null) { return null }c = checkReturn(r.returnUrl, a)if (c.error != null) { return null } // the origin was removed from the app sincereturn { rid = r.rid app = appRowOf(a) origin = c.origin returnUrl = r.returnUrl invite = r.invite == null ? '' : r.invite }}// The identity's connection to the app, made on the first loginstatic connectionOf = (appId, identityId) => {let pair = appId + ':' + identityIdc = first(connectionsTable.find('pair', pair))if (c != null) { return c }id = connectionsTable.put({ pair = pair app = appId identity = identityId created = now() })return connectionsTable.fetch(id)}// "SIGN OUT" OF AN APP (ticket #2): forgets this identity's connection to it (its// notification settings for the app). The identity's short id does not change: a later// login makes a fresh connection and the app gets the same id. Notifications already sent// through the old connection stay (as for a deleted app).static disconnectConnection = (accountId, id) => {if (!isId(id)) { return { error = 'no such app connection' } }c = connectionsTable.fetch(id)if (c == null) { return { error = 'no such app connection' } }ident = ownIdentity(accountId, c.identity)if (ident == null) { return { error = 'no such app connection' } }connectionsTable.delete(c.id)return { disconnected = id }}// A ONE-TIME CODE for app `appId` and identity `identityId` (public ids): 48 hex, single// use, grantTtl (60 s), this app only. The login button AND the selector (lib/selector.hl)// hand out these codes; the app's server trades one with `exchange` below.static issueCode = (appId, identityId) => {connectionOf(appId, identityId)code = randomBytes(24)grantsTable.put({ hash = sha256(code) app = appId identity = identityId expires = now() + grantTtl })return code}// THE CHOICE: the account picks one of its identities for the request. Answers { error }// or { url } — the app's return URL with a fresh one-time `ident_code`.static grantLogin = (accountId, rid, identityId) => {rq = requestOf(rid)if (rq == null) { return { error = 'this login request is unknown or expired — go back to the app and start again' } }ident = ownIdentity(accountId, identityId)if (ident == null) { return { error = 'no such identity' } }code = issueCode(rq.app.id, identityId)r = first(requestsTable.find('rid', rid))if (r != null) { requestsTable.delete(r.id) }sep = rq.returnUrl.includes('?') ? '&' : '?'return { url = rq.returnUrl + sep + 'ident_code=' + code }}// THE EXCHANGE (the app's server): answers { status, error } or { identity }. The app// authenticates with key + secret (401); a code works once, for its own app, for 60 s (400).// A code shown to the wrong app is spent: it has leaked.static exchange = (key, secret, code) => {a = appByKey(key)if (a == null || sha256(secret) != a.secretHash) { return { status = 401 error = 'unknown API key or wrong secret' } }g = first(grantsTable.find('hash', sha256(code)))if (g == null) { return { status = 400 error = 'unknown or already used code' } }grantsTable.delete(g.id)if (g.app != a.id) { return { status = 400 error = 'this code was not issued to this app' } }if (g.expires < now()) { return { status = 400 error = 'the code expired' } }c = first(connectionsTable.find('pair', a.id + ':' + g.identity))ident = isId(g.identity) ? identitiesTable.fetch(g.identity) : nullif (c == null || ident == null) { return { status = 400 error = 'the identity is no longer connected to this app' } }return { identity = ident.shortId }}// ---- AN INVITE'S LOGIN (ident#22; the invite records: lib/invites.hl) -------------------------------// GET /invite/<token>: answers { status, title, error } (an error page) or { rid } — a login// request of the app that carries the invite, so the login flow does the rest.static openInvite = (token) => {o = openableInvite(token)if (o.error != null) { return o }rec = o.invitelet rid = randomBytes(16)requestsTable.put({ rid = rid app = rec.app returnUrl = rec.returnUrl invite = rec.id expires = now() + requestTtl })return { rid = rid }}// THE CHOICE for an invite's request: the identity accepts it. Answers { error } or { url }// (the app's return URL with ident_code and invite). An identity that already accepted this// invite may pass again without taking another use.static grantInvite = (accountId, rid, identityId) => {rq = requestOf(rid)if (rq == null) { return { error = 'this login request is unknown or expired — open the invitation link again' } }rec = inviteById(rq.invite)if (rec == null || rec.app != rq.app.id) { return { error = 'this invitation no longer exists' } }if (ownIdentity(accountId, identityId) == null) { return { error = 'no such identity' } }conn = connectionOf(rq.app.id, identityId)a = acceptInvite(rec, identityId, conn)if (a.error != null) { return a }code = issueCode(rq.app.id, identityId)r = first(requestsTable.find('rid', rid))if (r != null) { requestsTable.delete(r.id) }sep = rq.returnUrl.includes('?') ? '&' : '?'return { url = rq.returnUrl + sep + 'ident_code=' + code + '&invite=' + rec.id }}// the face chooseIdentity (components/home.hl): a request that carries an invite accepts the invite, any// other is a plain login. Answers { error } or { url }.static grantRequest = (accountId, rid, identityId) => {rq = requestOf(rid)if (rq != null && rq.invite != '') { return grantInvite(accountId, rid, identityId) }return grantLogin(accountId, rid, identityId)}// ---- THE SHORT IDS (ident#23) ---------------------------------------------------------------------// WHAT THE APP MEANT BY AN ID: a notification or a lookup may name an identity by// its short id, or — until the app has migrated — by its old per-app id. Answers the// connection (of app `appId`) or null.static connectionByAnyId = (appId, given) => {ident = identityByShortId(given)if (ident != null) { return first(connectionsTable.find('pair', appId + ':' + ident.id)) }if (given == null || hlTypeName(given) != 'String') { return null }rows = connectionsTable.find('app', appId)if (countOf(rows) > 0) {for (c of rows) { if (c.appIdentity != null && c.appIdentity == given) { return c } }}return null}// THE MIGRATION, for the app's server: answers { ids } — every old per-app id// → the identity's short id (an identity that was deleted has none and is left out).// With `finish` the old ids are dropped afterwards: the app has stored the short ids.static migrateIds = (a, finish) => {let ids = {}let n = 0rows = connectionsTable.find('app', a.id)if (countOf(rows) > 0) {for (c of rows) {if (c.appIdentity == null) { continue }let ident = isId(c.identity) ? identitiesTable.fetch(c.identity) : nullif (ident != null) { ids[c.appIdentity] = ident.shortId n = n + 1 }if (finish) {let rec = {}for (k of c.keys()) { if (k != 'appIdentity') { rec[k] = c[k] } }connectionsTable.update(c.id, rec)}}}return { ids = ids count = n finished = finish }}
Branches
- mainmain branch
Latest commits
- 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