ident
All repositories: gitoria
9.0 KB
// invites.hl — THE INVITE SERVICE (ticket ident#22). Statics only, the server realm.// An app (key + secret) asks ident for an INVITE for its project and role and gets a link// `<ident>/invite/<token>`. Whoever opens the link joins with ident: the login button's// own flow (apps.hl) — email → code if signed out, the identity choice if signed in — and// ident sends the browser back to the app's `return` URL with `ident_code` (the identity)// AND `invite=<invite id>`. The app's server then asks POST /api/invites/get which identity// accepted the invite (compare it with the one the exchange gave). Concept: CONCEPT.md is// silent on invites; the ticket is the spec.//// invitesTable pk @id index @app, !hash (apps.hl)// { app (app @id), hash = sha256(token), project, role, returnUrl, uses, acceptedBy// ('a b c': the identities' short ids that accepted it (invites accepted before ident#23 hold the old per-app id), space separated),// expires, revoked (0 or the time), mailedAt (0 or the time), created }//// Single use by default (`uses`, up to 1000), 7 days by default (`days`, up to 90). The link// (token) is shown once, in the answer of the create call; only its sha256 is stored.// A state is one of: revoked, used (all uses taken), expired, open.import { now } from 'hl:time'import { randomBytes, sha256 } from 'hl:crypto'import { env } from 'hl:proc'import { invitesTable, requestsTable, requestOf, requestTtl, checkReturn, originsOf, issueCode, connectionOf } from './apps.hl'import { envNumber, countOf, first, merged, hasControl, validEmail, normEmail, isId, oldestFirst, ownIdentity, identitiesTable } from './store.hl'static dayMs = envNumber('IDENT_INVITE_DAY_MS', 86400000) // a day (the gate shortens it)static defaultUses = 1static maxUses = 1000static defaultDays = 7static maxDays = 90static maxText = 60// mails one app may send through ident per 24 h (an app must not turn ident into a mail cannon)static mailLimit = envNumber('IDENT_INVITE_MAIL_LIMIT', 50)static publicUrl = env('IDENT_PUBLIC_URL') != null && env('IDENT_PUBLIC_URL') != '' ? env('IDENT_PUBLIC_URL') : nullstatic isWhole = (n) => { return hlTypeName(n) == 'Number' && !('' + n).includes('.') && !('' + n).includes('e') }// ---- state ----------------------------------------------------------------------------static acceptedList = (rec) => { return rec.acceptedBy == null || rec.acceptedBy == '' ? [] : rec.acceptedBy.split(' ') }static usedCount = (rec) => { return acceptedList(rec).length }static stateOf = (rec) => {if (rec.revoked != null && rec.revoked > 0) { return 'revoked' }if (usedCount(rec) >= rec.uses) { return 'used' }if (rec.expires < now()) { return 'expired' }return 'open'}// what the app sees of an invite (never the token)static inviteRow = (rec) => {return { id = rec.id project = rec.project role = rec.role state = stateOf(rec) uses = rec.uses used = usedCount(rec) identities = acceptedList(rec) expires = rec.expires created = rec.created }}// the message of a state that cannot be acceptedstatic problemOf = (state) => {if (state == 'used') { return 'This invitation was already used.' }if (state == 'expired') { return 'This invitation has expired. Ask whoever invited you for a new one.' }if (state == 'revoked') { return 'This invitation was withdrawn. Ask whoever invited you for a new one.' }return ''}// ---- the app's calls -------------------------------------------------------------------static checkText = (v, name) => {if (v == null || hlTypeName(v) != 'String') { return { error = 'field ' + name + ' must be a string' } }let t = v.trim()if (t == '') { return { error = 'field ' + name + ' is empty' } }if (t.length > maxText) { return { error = 'field ' + name + ' is longer than ' + maxText + ' characters' } }if (hasControl(t)) { return { error = 'field ' + name + ' contains a control character' } }return { text = t }}static mailedLately = (appId) => {let n = 0let t0 = now() - dayMslet rows = invitesTable.find('app', appId)if (countOf(rows) > 0) { for (r of rows) { if (r.mailedAt != null && r.mailedAt > t0) { n = n + 1 } } }return n}// `base` is where the link points (the request's own origin when IDENT_PUBLIC_URL is unset).// answers { status, error } or { invite (row), url, mail (null | { to, days }) } — the caller mailsstatic createInvite = (appRec, b, base) => {let p = checkText(b.project, 'project')if (p.error != null) { return { status = 400 error = p.error } }let r = checkText(b.role, 'role')if (r.error != null) { return { status = 400 error = r.error } }let rt = checkReturn(b['return'], appRec)if (rt.error != null) { return { status = 400 error = rt.error } }let uses = b.uses == null ? defaultUses : b.usesif (!isWhole(uses) || uses < 1 || uses > maxUses) { return { status = 400 error = 'field uses must be a whole number from 1 to ' + maxUses } }let days = b.days == null ? defaultDays : b.daysif (!isWhole(days) || days < 1 || days > maxDays) { return { status = 400 error = 'field days must be a whole number from 1 to ' + maxDays } }let to = nullif (b.email != null) {to = normEmail(b.email)if (!validEmail(to)) { return { status = 400 error = 'field email is not an email address' } }if (mailedLately(appRec.id) >= mailLimit) { return { status = 429 error = 'too many invitation mails from this app in 24 hours' } }}let token = randomBytes(16)let t = now()let id = invitesTable.put({ app = appRec.id hash = sha256(token) project = p.text role = r.text returnUrl = b['return'] uses = uses acceptedBy = '' expires = t + days * dayMs revoked = 0 mailedAt = to != null ? t : 0 created = t })return { invite = inviteRow(invitesTable.fetch(id)) url = base + '/invite/' + token mail = to != null ? { to = to days = days } : null }}// the invite `id` if the app owns it, else nullstatic ownInvite = (appRec, id) => {if (!isId(id)) { return null }let r = invitesTable.fetch(id)if (r == null || r.app != appRec.id) { return null }return r}// all of the app's invites, oldest first; `project` (optional) narrows itstatic listInvites = (appRec, project) => {let out = []for (r of oldestFirst(invitesTable.find('app', appRec.id))) {if (project == null || r.project == project) { out.push(inviteRow(r)) }}return out}// answers { status, error } or { invite (row) }static revokeInvite = (appRec, id) => {let r = ownInvite(appRec, id)if (r == null) { return { status = 404 error = 'no such invite' } }let s = stateOf(r)if (s != 'open') { return { status = 409 error = 'the invite is not open (it is ' + s + ')' } }invitesTable.update(r.id, merged(r, { revoked = now() }))return { invite = inviteRow(invitesTable.fetch(r.id)) }}// ---- the person's side ------------------------------------------------------------------// GET /invite/<token>: answers { status, title, error } (an error page) or { rid } — a login// request of the app (apps.hl) that carries the invite, so the login flow does the rest.static openInvite = (token) => {let rec = token == null || hlTypeName(token) != 'String' || token.length > 64 ? null : first(invitesTable.find('hash', sha256(token)))if (rec == null) { return { status = 404 title = 'Unknown invitation' error = 'This invitation link is not valid. Check that you copied all of it, or ask whoever invited you for a new one.' } }let s = stateOf(rec)if (s != 'open') { return { status = 410 title = s == 'used' ? 'Invitation already used' : (s == 'expired' ? 'Invitation expired' : 'Invitation withdrawn') error = problemOf(s) } }let 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) => {let rq = requestOf(rid)if (rq == null) { return { error = 'this login request is unknown or expired — open the invitation link again' } }let rec = isId(rq.invite) ? invitesTable.fetch(rq.invite) : nullif (rec == null || rec.app != rq.app.id) { return { error = 'this invitation no longer exists' } }if (ownIdentity(accountId, identityId) == null) { return { error = 'no such identity' } }let conn = connectionOf(rq.app.id, identityId)let list = acceptedList(rec)let s = stateOf(rec)if (s == 'revoked') { return { error = problemOf(s) } }let mine = identitiesTable.fetch(identityId).shortIdif (!list.includes(mine) && !(conn.appIdentity != null && list.includes(conn.appIdentity))) {if (s != 'open') { return { error = problemOf(s) } }list.push(mine)invitesTable.update(rec.id, merged(rec, { acceptedBy = list.join(' ') }))}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 + '&invite=' + rec.id }}
Branches
- mainmain branch
Latest commits
- 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