tickets
All repositories: gitoria
11.9 KB
// 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 { env } from 'hl:proc'import { now } from 'hl:time'import { randomBytes, sha256 } from 'hl:crypto'import { fetch } from 'hl:fetch'import { localStamp } from './localtime.hl'static envOr = (name, fallback) => {let v = env(name)return v != null && v.trim() != '' ? v.trim() : fallback}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 userDir = env('TICKETS_STORAGE') != null ? env('TICKETS_STORAGE') : './storage/mpackdb'static usersTable = new MPackDB(file = userDir + '/users.db', primaryKey = '@id', indexes = ['!identity'])static tokensTable = new MPackDB(file = userDir + '/tokens.db', primaryKey = '@id', indexes = ['!hash', 'user'])static countOfList = (list) => {if (list == null) { return 0 }let n = list.lengthreturn n == null ? 0 : n}static firstOf = (list) => { return countOfList(list) > 0 ? list[0] : null }// a query value, percent-encoded (encodeURIComponent is a global, hybriel#14)static encode = (s) => { return encodeURIComponent('' + s) }// ---- 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}// only lowercase hex (ident's one-time codes are 48 hex)static isHex = (s, max) => {if (s == null || hlTypeName(s) != 'String' || s.length == 0 || s.length > max) { return false }let i = 0while (i < s.length) {let c = s.charCodeAt(i)if (!((c >= 48 && c <= 57) || (c >= 97 && c <= 102))) { 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)}// the user of an identity id, made at its first login (name '' = not chosen yet)static ensureUser = (identity) => {let u = firstOf(usersTable.find('identity', 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 firstOf(usersTable.find('identity', 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}// a short one-line text: trimmed, at most `max` characters, no control charactersstatic plainError = (s, max, what) => {if (s == null || hlTypeName(s) != 'String') { return what + ' must be text' }let t = s.trim()if (t.length > max) { return what + ' is too long (at most ' + max + ' characters)' }let i = 0while (i < t.length) {let c = t.charCodeAt(i)if (c < 32 || c == 127) { return what + ' must be one line without control characters' }i = i + 1}return null}// 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 (countOfList(all) == 0) { return out }for (t of all) { out.push(tokenRowOf(t)) }let i = 1while (i < out.length) {let cur = out[i]let j = i - 1while (j >= 0 && out[j].createdMs < cur.createdMs) {out[j + 1] = out[j]j = j - 1}out[j + 1] = curi = i + 1}return out}// 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 = firstOf(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) }}
Branches
- mainmain branch
Latest commits
- 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