tickets
All repositories: gitoria
10.8 KB
// lib/connections.hl — AN APP IS CONNECTED TO ONE PROJECT (ticket tickets.worldapi.org#21; first app: gitoria).// Statics only. One hl:mpackdb table beside the others (storage/mpackdb/connections.db, UUID keys)://// connectionsTable pk @id index nonce, hash, codeHash, project// { status, nonce, app, label, returnUrl, state, project, createdBy, codeHash, expires, hash, created }//// THE FLOW (README "Connecting an app"):// 1. the app sends the browser to <tickets>/connect?app=gitoria&label=<owner/repo>&return=<url>&state=<opaque>// → a REQUEST (status 'request', an unguessable nonce) and a redirect to /connect/<nonce> (components/connect.hl).// 2. the person logs in (ident), picks a project they are ADMIN of — or makes a new one — and confirms.// The request becomes a connection (status 'waiting') with a ONE-TIME CODE (5 minutes); the page links back to// `return?code=<code>&state=<state>`.// 3. the app exchanges the code on the server: POST /api/connect/exchange { code } → { key, project }.// The connection is 'active'; only the sha256 of the key is stored (`tktc_` + 48 hex, shown once).// 4. the key acts inside THAT ONE project only (lib/api-helpers.hl apiAuth / actorIn): create tickets, comment, change state.// Every write names the person: header `X-Tickets-Identity: <ident public id>`; the person is a tickets user// (they logged in here once and chose a name) and the project's ROLES apply to them as to a token.// 5. the project page shows "connected to <app>: <label>"; an admin's "Disconnect" deletes the row — the key is dead at once.// The return URL is the app's: only https on worldapi.org (and its subdomains) — or an origin listed in// TICKETS_CONNECT_ORIGINS (comma separated, e.g. http://127.0.0.1:8700 for a dev copy). The page shows the host.import { MPackDB } from 'hl:mpackdb'import { now } from 'hl:time'import { randomBytes, sha256 } from 'hl:crypto'import { env } from 'hl:proc'import { storageDir, first, countOf, isHex, plainError } from './util.hl'import { isIdentId, userOfIdentity, nameOfUser, publicUrl } from './users.hl'import { projectRecord, projectRecords, isAdminOf, createProject, userOk, notAdmin } from './projects.hl'static connectionsTable = new MPackDB(file = storageDir + '/connections.db', primaryKey = '@id', indexes = ['nonce', 'hash', 'codeHash', 'project'])static requestTtlMs = 3600000static codeTtlMs = 300000static extraOrigins = () => {v = env('TICKETS_CONNECT_ORIGINS')out = []if (v == null || v.trim() == '') { return out }for (o of v.split(',')) { if (o.trim() != '') { out.push(o.trim()) } }return out}// the characters a return URL may hold: URL-safe, no spaces, no quotes, no '#' (the code goes in the query)static urlChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~/?&=%+,;:@!$()*'// the host (with port) of an absolute URL: everything between '://' and the next '/' or '?'static authorityOf = (url) => {at = url.indexOf('://')if (at < 0) { return '' }rest = url.slice(at + 3)let end = rest.lengths = rest.indexOf('/')if (s >= 0 && s < end) { end = s }q = rest.indexOf('?')if (q >= 0 && q < end) { end = q }return rest.slice(0, end)}// null when the URL may be an app's return address, else the reasonstatic returnError = (url) => {if (url == null || hlTypeName(url) != 'String' || url == '') { return 'the app sent no return address' }if (url.length > 500) { return 'the return address is too long' }let i = 0while (i < url.length) {if (!urlChars.includes(url[i])) { return 'the return address holds a character that is not allowed' }i = i + 1}for (o of extraOrigins()) {if (url == o || url.startsWith(o + '/') || url.startsWith(o + '?')) { return null }}if (!url.startsWith('https://')) { return 'the return address must be https on worldapi.org' }host = authorityOf(url)if (host == '' || host.includes('@') || host.includes(':')) { return 'the return address has an unusual host' }if (host != 'worldapi.org' && !host.endsWith('.worldapi.org')) { return 'the return address must be on worldapi.org' }return null}static hostOf = (url) => { return authorityOf(url) }static sepOf = (url) => { return url.includes('?') ? '&' : '?' }// ---- 1. the request ---------------------------------------------------------------------------// answers { nonce } or { error }static createRequest = (app, label, returnUrl, state) => {a = app == null ? '' : ('' + app).trim()if (a == '') { return { error = 'the app sent no name' } }let bad = plainError(a, 60, 'the app name')if (bad == null) { bad = plainError(label == null ? '' : label, 100, 'the label') }if (bad == null) { bad = plainError(state == null ? '' : state, 200, 'the state') }if (bad == null) { bad = returnError(returnUrl) }if (bad != null) { return { error = bad } }let nonce = randomBytes(16)id = connectionsTable.put({ status = 'request' nonce = nonce app = a label = label == null ? '' : label.trim() returnUrl = returnUrl state = state == null ? '' : state project = '' createdBy = '' codeHash = '' hash = '' expires = now() + requestTtlMs created = now() })if (id == null) { return { error = 'could not store the request: ' + connectionsTable.lastError() } }return { nonce = nonce }}// the open request of a nonce, or null (unknown, used, expired)static requestOf = (nonce) => {if (!isHex(nonce, 64)) { return null }c = first(connectionsTable.find('nonce', nonce))if (c == null || c.status != 'request' || c.expires < now()) { return null }return c}// what the connect page shows of a requeststatic requestView = (c) => {return { app = c.app label = c.label hasLabel = c.label != '' host = hostOf(c.returnUrl) }}// the projects a user is an ADMIN of: [{ id, title, slug }]static adminProjects = (user) => {out = []if (user == null) { return out }for (p of projectRecords()) { if (isAdminOf(p.id, user)) { out.push({ id = p.id title = p.title slug = p.slug }) } }return out}// ---- 2. the confirmation ----------------------------------------------------------------------// `projectId` '' = make a new project titled `title` (the person becomes its admin). Answers { error, forbidden? } or// { url (back to the app, with the one-time code), project (title), slug }static confirmRequest = (nonce, user, projectId, title) => {c = requestOf(nonce)if (c == null) { return { error = 'this request is used up or expired — start again from the app' } }if (!userOk(user)) { return { error = 'log in with ident and choose a display name first' } }let p = nullif (projectId == null || projectId == '') {r = createProject(user, title, '', '')if (r.error != null) { return { error = r.error } }p = projectRecord(r.project.id)} else {p = projectRecord('' + projectId)if (p == null) { return { error = 'no such project' } }if (!isAdminOf(p.id, user)) { return notAdmin }}// a second connection of the same app and label to the same project replaces the first: its key diesfor (o of connectionsOf(p.id, true)) { if (o.app == c.app && o.label == c.label && o.id != c.id) { connectionsTable.delete(o.id) } }code = randomBytes(24)c.status = 'waiting'c.nonce = ''c.project = p.idc.createdBy = user.idc.codeHash = sha256(code)c.expires = now() + codeTtlMsconnectionsTable.update(c.id, c)let url = c.returnUrl + sepOf(c.returnUrl) + 'code=' + codeif (c.state != '') { url = url + '&state=' + encodeURIComponent(c.state) }return { url = url project = p.title slug = p.slug }}// ---- 3. the exchange --------------------------------------------------------------------------// answers { key, project (slug), title, api } or { error }static exchangeConnectCode = (code) => {if (!isHex(code, 200)) { return { error = 'that is not a connection code' } }c = first(connectionsTable.find('codeHash', sha256(code)))if (c == null || c.status != 'waiting' || c.expires < now()) { return { error = 'unknown, used or expired code — connect again' } }p = projectRecord(c.project)if (p == null) { return { error = 'the project is gone' } }let key = 'tktc_' + randomBytes(24)c.status = 'active'c.hash = sha256(key)c.codeHash = ''c.expires = 0c.connected = now()connectionsTable.update(c.id, c)return { key = key project = p.slug title = p.title api = publicUrl + '/api/projects/' + p.slug }}// ---- 4. the key -------------------------------------------------------------------------------static isKeyHeader = (header) => {if (header == null || hlTypeName(header) != 'String') { return false }h = header.trim()return (h.startsWith('Bearer ') || h.startsWith('bearer ')) && h.slice(7).trim().startsWith('tktc_')}// `Authorization: Bearer tktc_…` → the ACTIVE connection, or nullstatic connectionOfKey = (header) => {if (!isKeyHeader(header)) { return null }key = header.trim().slice(7).trim()if (key.length != 53) { return null }c = first(connectionsTable.find('hash', sha256(key)))if (c == null || c.status != 'active') { return null }return c}// the person an app write names (`X-Tickets-Identity`): { user } or { error }static personOf = (identity) => {if (identity == null || hlTypeName(identity) != 'String' || !isIdentId(identity.trim())) {return { error = 'a write through a connection names the person: header X-Tickets-Identity: <their ident id>' }}u = userOfIdentity(identity.trim())if (u == null || u.name == '') { return { error = 'that person has not logged in to tickets yet — they log in once and choose a display name' } }return { user = u }}// ---- 5. the project page ----------------------------------------------------------------------// the connections of a project: [{ id, app, label, by, when }]; `all` = also the waiting onesstatic connectionsOf = (projectId, all) => {out = []rows = connectionsTable.find('project', projectId)if (countOf(rows) == 0) { return out }for (c of rows) { if (all || c.status == 'active') { out.push(c) } }return out}static connectionRows = (projectId) => {out = []for (c of connectionsOf(projectId, false)) {out.push({ id = c.id app = c.app label = c.label hasLabel = c.label != '' by = nameOfUser(c.createdBy) text = c.app + (c.label != '' ? ': ' + c.label : '') })}return out}// an admin ends a connection; the key is dead at once. answers { connections } or { error, forbidden? }static disconnect = (projectId, actor, connectionId) => {p = projectRecord(projectId)if (p == null) { return { error = 'no such project' } }if (!userOk(actor)) { return { error = 'log in with ident and choose a display name first' } }if (!isAdminOf(p.id, actor)) { return notAdmin }if (connectionId == null || hlTypeName(connectionId) != 'String' || connectionId == '') { return { error = 'no such connection' } }c = connectionsTable.fetch(connectionId)if (c == null || c.project != p.id) { return { error = 'no such connection' } }connectionsTable.delete(c.id)return { connections = connectionRows(p.id) }}
Branches
- mainmain branch
Latest commits
- a75e0279mission 010 (code order) 3/4: let only where a variable is reassigned or re-bound in a loop body (456 lets → plain declarations; Hybriel refuses a plain declaration inside a loop on its 2nd pass). gate 249/0, connect 60/0, real-data reads identical, a 50-step write sequence (API + faces) identical to the old codemre
- 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