tickets
All repositories: gitoria
10.9 KB
// lib/users.hl — WHO WRITES (ticket tickets.worldapi.org#7, mission 011; CONCEPT.md "Login via// ident"). Reading stays public; writing needs a login through ident. Two hl:mpackdb tables// beside the ticket tables (storage/mpackdb/, UUID keys — creator's convention)://// usersTable pk @id index !identity { identity, name, created }// identity = ident's PER-APP identity id (32 hex) — the answer of POST <ident>/api/exchange.// It stays SERVER SIDE (ident CONCEPT: "the app never exposes the identity id");// the one exception is the user's own /you page (mission 011: the architect// needs the creator's id for TICKETS_CREATOR_IDENTITY).// name = the DISPLAY NAME, asked once at the first login (CONCEPT point 6). An ordinary// field ('' until chosen) so ident can fill it later (the "handshake" properties).// tokensTable pk @id index !hash user { user (users @id), hash = sha256(token), label, created }// API TOKENS for machine clients (CONCEPT point 5): `tkt_` + 48 hex, shown ONCE; only the// sha256 is stored. `Authorization: Bearer <token>` acts as that user. Revoke = the row goes.//// THE SESSION (hl:web) carries `user = { id = <users @id> }` only — hl:web ships// `session.user` to the page, so never the identity id. `session.data.tag` is a random tag of// this login: the audience addresses `signedIn` / `signedOut` to THIS session's tabs by it.//// Config (environment, or `.env` beside project.hl — the runtime loads it; never commit it):// IDENT_URL ident's public origin (selector script, login button). Default https://ident.worldapi.org// IDENT_EXCHANGE_URL where the SERVER posts /api/exchange. Default = IDENT_URL// IDENT_API_KEY / IDENT_API_SECRET this app's registration in ident (pk_… public, sk_… secret)// TICKETS_PUBLIC_URL this app's public origin (the login button's return URL). Default https://tickets.worldapi.org// TICKETS_CREATOR_IDENTITY the creator's ident id (a short id like a68sz; before ident#23 32 hex). Since ticket #20 there is// no creator-only workflow: it is read ONLY by migrate.hl, which makes this person the admin of// the projects that existed before roles. Unset = those projects get no admin.import { MPackDB } from 'hl:mpackdb'import { now } from 'hl:time'import { randomBytes, sha256 } from 'hl:crypto'import { fetch } from 'hl:fetch'import { localStamp, envOr, storageDir, countOf, first, encode, isHex, plainError, newestFirst } from './util.hl'static identUrl = envOr('IDENT_URL', 'https://ident.worldapi.org')static identExchangeUrl = envOr('IDENT_EXCHANGE_URL', identUrl)static identKey = envOr('IDENT_API_KEY', '')static identSecret = envOr('IDENT_API_SECRET', '')static publicUrl = envOr('TICKETS_PUBLIC_URL', 'https://tickets.worldapi.org')static creatorIdentity = envOr('TICKETS_CREATOR_IDENTITY', '')static usersTable = new MPackDB(file = storageDir + '/users.db', primaryKey = '@id', indexes = ['!identity'])static tokensTable = new MPackDB(file = storageDir + '/tokens.db', primaryKey = '@id', indexes = ['!hash', 'user'])// ---- the two ways in (ident README "How apps use ident") ---------------------------------static selectorScript = identUrl + '/selector.js'static callbackUrl = publicUrl + '/login/callback'static loginHref = identUrl + '/login?key=' + identKey + '&return=' + encode(callbackUrl)// an identity id as ident answers it: its public SHORT ID since ident#23 (5 characters like `a68sz`: 2-9 and a-z),// before that the old per-app id (32 hex) — lower case letters and digits, at most 64 (as gitoria's users.hl)static isIdentId = (s) => {if (s == null || hlTypeName(s) != 'String' || s.length == 0 || s.length > 64) { return false }let i = 0while (i < s.length) {let c = s.charCodeAt(i)if (!((c >= 48 && c <= 57) || (c >= 97 && c <= 122))) { return false }i = i + 1}return true}// A FAILED FETCH (refused connection, timeout, TLS) is an `Error` event, not an answer: the// global `on Error` in project.hl absorbs it, the fetch then yields null → "ident did not answer"// (a 400 page / a message) instead of a 500 with source paths. (A handler in THIS file does not// catch it — mission 011.)// THE EXCHANGE: POST <ident>/api/exchange { key, secret, code } → { identity } | { error }static exchangeCode = (code) => {if (identKey == '' || identSecret == '') { return { error = 'login is not set up on this server (IDENT_API_KEY / IDENT_API_SECRET missing)' } }if (!isHex(code, 200)) { return { error = 'that is not an ident login code' } }let r = fetch(identExchangeUrl + '/api/exchange', { method = 'POST' json = { key = identKey secret = identSecret code = code } headers = { 'user-agent' = 'tickets.worldapi.org (ident exchange)' } timeoutMs = 10000 })if (r == null || r.status == null || r.status == 0) { return { error = 'ident did not answer' } }let j = r.status == 200 ? r.json() : nullif (j == null || j.identity == null || !isIdentId(j.identity)) {let why = ''if (r.status != 200) {let e = r.json()why = e != null && e.error != null ? ': ' + e.error : ''}return { error = 'ident refused the login (' + r.status + why + ')' }}return { identity = j.identity }}// ---- users ------------------------------------------------------------------------------static userRecord = (userId) => {if (userId == null || hlTypeName(userId) != 'String' || userId == '') { return null }return usersTable.fetch(userId)}// every user (projects.hl userByRef looks a person up among them)static userRecords = () => { return usersTable.find(null, null) }// the user of an identity id, or nullstatic userOfIdentity = (identity) => { return first(usersTable.find('identity', identity)) }// the user of an identity id, made at its first login (name '' = not chosen yet)static ensureUser = (identity) => {let u = userOfIdentity(identity)if (u != null) { return u }let id = usersTable.put({ identity = identity name = '' created = now() })if (id == null) { return null }return usersTable.fetch(id)}// THE SITE CREATOR'S user record (TICKETS_CREATOR_IDENTITY), or null while they never logged instatic creatorUser = () => {if (creatorIdentity == '') { return null }return userOfIdentity(creatorIdentity)}// the same, made if missing (migrate.hl: the creator is admin before their first login here)static ensureCreatorUser = () => {if (creatorIdentity == '') { return null }return ensureUser(creatorIdentity)}// what a page may know about a user: NEVER the identity idstatic userInfo = (u) => {return { name = u.name named = u.name != '' }}static userOfSession = (session) => {if (session == null || session.user == null) { return null }return userRecord(session.user.id)}static infoOfSession = (session) => {let u = userOfSession(session)return u == null ? null : userInfo(u)}// may this session write (logged in AND a display name chosen)?static canWriteSession = (session) => {let u = userOfSession(session)return u != null && u.name != ''}// THE WRITER of a web write: { user } or { error } (the message the page shows)static writerOfSession = (session) => {let u = userOfSession(session)if (u == null) { return { error = 'log in with ident (top right) to write' } }if (u.name == '') { return { error = 'choose a display name first (top of the page)' } }return { user = u }}// the author a history shows for an event written by a user: the CURRENT display namestatic nameOfUser = (userId) => {let u = userRecord(userId)if (u == null || u.name == '') { return 'someone' }return u.name}// the display name: 1–60 characters, one line. Asked ONCE (CONCEPT point 6): a name that is// set is not changed here (no rename UI — an open question for the creator)static setUserName = (userId, name) => {let u = userRecord(userId)if (u == null) { return { error = 'not logged in' } }if (u.name != '') { return { error = 'your display name is already set' } }let bad = plainError(name, 60, 'the display name')if (bad != null) { return { error = bad } }let n = name.trim()if (n == '') { return { error = 'the display name must not be empty' } }// the whole record, its `id` included (hl:mpackdb refuses an @id record without it: CorruptRecord)usersTable.update(u.id, { id = u.id identity = u.identity name = n created = u.created })let after = usersTable.fetch(u.id)if (after == null || after.name != n) { return { error = 'could not store the name: ' + usersTable.lastError() } }return { user = after }}// ---- API tokens ---------------------------------------------------------------------------static tokenRowOf = (t) => {return { id = t.id label = t.label != null && t.label != '' ? t.label : '(no label)' created = localStamp(t.created) createdMs = t.created }}// the user's tokens, newest firststatic tokenRows = (userId) => {let out = []let all = tokensTable.find('user', userId)if (countOf(all) == 0) { return out }for (t of all) { out.push(tokenRowOf(t)) }return newestFirst(out, 'createdMs')}// answers { token (shown ONCE), tokens } or { error }static createToken = (userId, label) => {let bad = plainError(label == null ? '' : label, 60, 'the label')if (bad != null) { return { error = bad } }let token = 'tkt_' + randomBytes(24)let id = tokensTable.put({ user = userId hash = sha256(token) label = (label == null ? '' : label.trim()) created = now() })if (id == null) { return { error = 'could not store the token: ' + tokensTable.lastError() } }return { token = token tokens = tokenRows(userId) }}// only the owner revokes; the row goes, the token is dead at oncestatic revokeToken = (userId, tokenId) => {if (tokenId == null || hlTypeName(tokenId) != 'String' || tokenId == '') { return { error = 'no such token' } }let t = tokensTable.fetch(tokenId)if (t == null || t.user != userId) { return { error = 'no such token' } }tokensTable.delete(t.id)return { tokens = tokenRows(userId) }}// `Authorization: Bearer <token>` → the user, or null (missing, malformed, unknown, revoked)static userOfBearer = (header) => {if (header == null || hlTypeName(header) != 'String') { return null }let h = header.trim()if (!h.startsWith('Bearer ') && !h.startsWith('bearer ')) { return null }let token = h.slice(7).trim()if (!token.startsWith('tkt_') || token.length != 52) { return null }let t = first(tokensTable.find('hash', sha256(token)))if (t == null) { return null }return userRecord(t.user)}// ---- the /you page: the user's OWN data (the only place the identity id is shown) -----------static youOf = (session) => {let u = userOfSession(session)if (u == null) { return null }return { name = u.name named = u.name != '' identity = u.identity tokens = tokenRows(u.id) }}// the random tag of a session's login (session.data.tag): project.hl's audience sends `signedIn` / `signedOut` to the// tabs of the session that carries itstatic tagOf = (session) => { return session != null && session.data != null ? session.data.tag : null }
Branches
- mainmain branch
Latest commits
- e9d5c618mission 010 (code order) 2/4: one lib/ file per topic — store.hl split into projects / tickets (+ relations) / events / tickets-helpers, util.hl shared helpers (env, storage dir, URLs, sorts, Vienna time), the function routes out of project.hl into lib/api.hl (thin; auth/filters/Accept in api-helpers.hl), invite + member-removal logic out of the faces/routes into invites.hl / tickets.hl; project.hl is the map. /login/callback gets req + the session store by reference. gate 249/0, connect 60/0, real-data reads identicalmre
- 97e269b5mission 010 (code order) 1/4: .hl files out of the root — lib/ (store, users, connections, invites, migrate, markdown, mdview, import = ticketfile, util = localtime, jsoncheck, api-helpers = api), tools/import.hl, components/styles.hl; import paths only. gate 249/0, connect 60/0, real-data reads identicalmre
- 38f9d10ftickets: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gate 249/0, connect 60/0mre
- d3db6139tickets: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gate 249/0, connect 60/0mre
- bce182e3tickets: Hybriel master 7eea0d32 (#126 memory, #48 lambda copies its argument); migrate.hl lambdas take &logmre
- 4137be0fantcolony#40: mission references point to the moved missionsmre
- 9bfba36aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- c7bd2645tickets: Hybriel master 73267707 (#122 fixed); compactNow workaround removed (#110 covered)mre
- 2ab91ee9tickets: gate checks rows appear once (session sync); re-vendor to ff51cf46 stopped on hybriel#122, stays 837fe120mre
- e01c2b1dtickets#24: installable app (manifest, service worker, offline list), own icon; gate waits for the hello's pongmre
- 752fbb7fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- 38bdd5e4deploy.sh: never send .git or .gitignore to Byrodinmre
- f12fa1bcState of 2026-09-27, before the move to gitoriamre