ident
All repositories: gitoria
16.0 KB
// apps.hl — APPS AND THE LOGIN BUTTON (piece 2 of 6, ticket #25; CONCEPT.md "Apps",// "One id per app", "Flow 1: login button"). Statics only, the server realm.//// 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, store.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/ (store.hl `dir`).import { MPackDB } from 'hl:mpackdb'import { now } from 'hl:time'import { randomBytes, sha256 } from 'hl:crypto'import { dir, envNumber, countOf, first, merged, hasControl, ownIdentity, isId, oldestFirst, identitiesTable, identityByShortId } from './store.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'])// INVITES (ident#22, invites.hl owns the rules; the table is here so deleting an app can drop its invites)static invitesTable = new MPackDB(file = dir + '/invites.db', primaryKey = '@id', indexes = ['@app' '!hash'])static grantTtl = envNumber('IDENT_GRANT_TTL_MS', 60000) // a one-time code: 60 sstatic requestTtl = 1800000 // a login button press: 30 minstatic maxOrigins = 10static maxName = 60// ---- validation -----------------------------------------------------------------------static isList = (v) => { return v != null && hlTypeName(v) == 'Hybrid' && v.length != null }static isObject = (v) => { return v != null && hlTypeName(v) == 'Hybrid' && v.length == null }static checkName = (name) => {if (name == null || hlTypeName(name) != 'String') { return { error = 'field name must be a string' field = 'name' } }let t = name.trim()if (t == '') { return { error = 'the app needs a name' field = 'name' } }if (t.length > maxName) { return { error = 'field name is longer than ' + maxName + ' characters' field = 'name' } }if (hasControl(t)) { return { error = 'field name contains a control character' field = 'name' } }return { name = t }}// AN ORIGIN is `http(s)://host[:port]`: lowercase, no path, no user, no query. One// trailing slash is forgiven. Answers { origin } or { error }.static hostChars = 'abcdefghijklmnopqrstuvwxyz0123456789.-[]:'static checkOrigin = (o) => {if (o == null || hlTypeName(o) != 'String') { return { error = 'an origin must be a string' } }let t = o.trim().toLowerCase()if (t.length > 0 && t[t.length - 1] == '/') { t = t.slice(0, t.length - 1) }if (t.length > 200) { return { error = 'the origin ' + t + ' is too long' } }let scheme = t.slice(0, 7) == 'http://' ? 'http://' : (t.slice(0, 8) == 'https://' ? 'https://' : null)if (scheme == null) { return { error = 'an origin starts with http:// or https:// (' + t + ')' } }let host = t.slice(scheme.length, t.length)if (host == '' || host[0] == '.' || host[0] == ':') { return { error = 'the origin ' + t + ' has no host' } }let i = 0while (i < host.length) {if (!hostChars.includes(host[i])) { return { error = 'an origin is only scheme://host[:port], no path (' + t + ')' } }i = i + 1}return { origin = t }}// the app form's input, STRICT: exactly { name, origins } — origins a list of 1-10// strings. Answers { error, field } or { name, origins (list, deduplicated) }.static appFields = ['name' 'origins']static checkAppInput = (input) => {if (!isObject(input)) { return { error = 'the app must be an object' field = '' } }for (k of input.keys()) {if (!appFields.includes(k)) { return { error = 'unknown field: ' + k field = k } }}for (k of appFields) {if (input[k] == null) { return { error = 'missing field: ' + k field = k } }}let n = checkName(input.name)if (n.error != null) { return n }if (!isList(input.origins)) { return { error = 'field origins must be a list of strings' field = 'origins' } }let out = []for (o of input.origins) {let c = checkOrigin(o)if (c.error != null) { return { error = c.error field = 'origins' } }if (!out.includes(c.origin)) { out.push(c.origin) }}if (out.length == 0) { return { error = 'give at least one origin the app runs on' field = 'origins' } }if (out.length > maxOrigins) { return { error = 'at most ' + maxOrigins + ' origins' field = 'origins' } }return { name = n.name origins = out }}// ---- apps -----------------------------------------------------------------------------static newKey = () => { return 'pk_' + randomBytes(16) } // 32 hexstatic newSecret = () => { return 'sk_' + randomBytes(24) } // 48 hexstatic originsOf = (rec) => { return rec.origins == '' ? [] : rec.origins.split(' ') }static appRowOf = (rec) => {if (rec == null) { return null }let origins = originsOf(rec)return { id = rec.id name = rec.name origins = origins originsText = origins.join(' ') apiKey = rec.apiKey }}// the account's apps, oldest first (stored `created`, never key order)static appRows = (accountId) => {let 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 }let 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))}// answers { error, field } or { app (row), secret } — the secret leaves ident only herestatic createApp = (accountId, input) => {let c = checkAppInput(input)if (c.error != null) { return c }let secret = newSecret()let 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) => {let r = ownApp(accountId, id)if (r == null) { return { error = 'no such app' field = 'id' } }let 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) => {let 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) with it; a pending login// request or code for it dies because its app is gonestatic deleteApp = (accountId, id) => {let r = ownApp(accountId, id)if (r == null) { return { error = 'no such app' } }let conns = connectionsTable.find('app', id)if (countOf(conns) > 0) { for (c of conns) { connectionsTable.delete(c.id) } }let invs = invitesTable.find('app', id)if (countOf(invs) > 0) { for (v of invs) { invitesTable.delete(v.id) } }appsTable.delete(r.id)return { deleted = id }}// ---- the login button ---------------------------------------------------------------// A RETURN URL is absolute http(s), printable, no fragment, at most 2000 chars, and its// origin is one of the app's. Answers { error } or { origin }.static urlChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~:/?&=%+,;@!$()*[]'static checkReturn = (url, appRec) => {if (url == null || url == '') { return { error = 'the return parameter is missing' } }if (url.length > 2000) { return { error = 'the return URL is too long' } }let i = 0while (i < url.length) {if (!urlChars.includes(url[i])) { return { error = 'the return URL contains a character that is not allowed' } }i = i + 1}let lower = url.toLowerCase()let scheme = lower.slice(0, 7) == 'http://' ? 'http://' : (lower.slice(0, 8) == 'https://' ? 'https://' : null)if (scheme == null) { return { error = 'the return URL must start with http:// or https://' } }let rest = lower.slice(scheme.length, lower.length)let end = rest.lengthfor (ch of ['/' '?']) {let at = rest.indexOf(ch)if (at >= 0 && at < end) { end = at }}let host = rest.slice(0, end)if (host == '') { return { error = 'the return URL has no host' } }let origin = scheme + hostif (!originsOf(appRec).includes(origin)) { return { error = 'the return URL is on ' + origin + ', which is not one of the origins registered for ' + appRec.name } }return { origin = origin }}// the login button's GET /login?key=&return= : answers { error } or { rid }static openRequest = (key, returnUrl) => {let 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' } }let 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 }let r = first(requestsTable.find('rid', rid))if (r == null) { return null }if (r.expires < now()) {requestsTable.delete(r.id)return null}let a = isId(r.app) ? appsTable.fetch(r.app) : nullif (a == null) { return null }let 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 + ':' + identityIdlet c = first(connectionsTable.find('pair', pair))if (c != null) { return c }let 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' } }let c = connectionsTable.fetch(id)if (c == null) { return { error = 'no such app connection' } }let 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 (selector.hl)// hand out these codes; the app's server trades one with `exchange` below.static issueCode = (appId, identityId) => {connectionOf(appId, identityId)let 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) => {let rq = requestOf(rid)if (rq == null) { return { error = 'this login request is unknown or expired — go back to the app and start again' } }let ident = ownIdentity(accountId, identityId)if (ident == null) { return { error = 'no such identity' } }let code = issueCode(rq.app.id, identityId)let r = first(requestsTable.find('rid', rid))if (r != null) { requestsTable.delete(r.id) }let 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) => {let a = appByKey(key)if (a == null || sha256(secret) != a.secretHash) { return { status = 401 error = 'unknown API key or wrong secret' } }let 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' } }let c = first(connectionsTable.find('pair', a.id + ':' + g.identity))let 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 }}// WHAT THE APP MEANT BY AN ID (ident#23): 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) => {let ident = identityByShortId(given)if (ident != null) { return first(connectionsTable.find('pair', appId + ':' + ident.id)) }if (given == null || hlTypeName(given) != 'String') { return null }let 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 (ident#23), 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 = 0let rows = 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
- 81b15b7bState of 2026-09-27, before the move to gitoriamre