ident
All repositories: gitoria
7.5 KB
// lib/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 (lib/apps.hl openInvite / grantInvite) — 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.// This file holds the invites table and every write to it; the checks: lib/invites-helpers.hl.//// invitesTable pk @id index @app, !hash// { 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 { MPackDB } from 'hl:mpackdb'import { now } from 'hl:time'import { randomBytes, sha256 } from 'hl:crypto'import { env } from 'hl:proc'import { dir, envNumber, countOf, first, merged, validEmail, normEmail, isId, oldestFirst } from './util.hl'import { checkReturn } from './apps-helpers.hl'import { isWhole, checkText, acceptedList, usedCount, problemOf } from './invites-helpers.hl'import { identitiesTable } from './identities.hl'static invitesTable = new MPackDB(file = dir + '/invites.db', primaryKey = '@id', indexes = ['@app' '!hash'])static dayMs = envNumber('IDENT_INVITE_DAY_MS', 86400000) // a day (the gate shortens it)static defaultUses = 1static maxUses = 1000static defaultDays = 7static maxDays = 90// 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') : null// ---- state ----------------------------------------------------------------------------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 app's calls -------------------------------------------------------------------static mailedLately = (appId) => {let n = 0t0 = now() - dayMsrows = 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) => {p = checkText(b.project, 'project')if (p.error != null) { return { status = 400 error = p.error } }r = checkText(b.role, 'role')if (r.error != null) { return { status = 400 error = r.error } }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' } }}token = randomBytes(16)t = now()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 }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) => {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) => {r = ownInvite(appRec, id)if (r == null) { return { status = 404 error = 'no such invite' } }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)) }}// DELETING AN APP (lib/apps.hl deleteApp) drops its invites with itstatic dropInvitesOf = (appId) => {invs = invitesTable.find('app', appId)if (countOf(invs) > 0) { for (v of invs) { invitesTable.delete(v.id) } }return null}// ---- the person's side (the login flow is lib/apps.hl openInvite / grantInvite) ---------------// GET /invite/<token>: answers { status, title, error } (an error page) or { invite (record) } — one that can// still be acceptedstatic openableInvite = (token) => {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.' } }s = stateOf(rec)if (s != 'open') { return { status = 410 title = s == 'used' ? 'Invitation already used' : (s == 'expired' ? 'Invitation expired' : 'Invitation withdrawn') error = problemOf(s) } }return { invite = rec }}static inviteById = (id) => { return isId(id) ? invitesTable.fetch(id) : null }// THE IDENTITY ACCEPTS the invite `rec` (its connection `conn` to the app is made already). Answers { error } or// { ok }. An identity that already accepted this invite may pass again without taking another use.static acceptInvite = (rec, identityId, conn) => {list = acceptedList(rec)s = stateOf(rec)if (s == 'revoked') { return { error = problemOf(s) } }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(' ') }))}return { ok = true }}
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