ident
All repositories: gitoria
18.3 KB
// testapp/project.hl — THE SMALLEST APP THAT USES "login with ident" (ident piece 2,// ticket #25). It proves the login button flow end to end (ident README "How apps use ident"):// 1. register this app in ident (/apps): name + the origin this app runs on// (TESTAPP_URL). Paste the API key and the secret into this app's setup form.// 2. `/` links the browser to <ident>/login?key=<api key>&return=<this app>/callback// 3. ident signs the browser in, the user chooses an identity, ident redirects to// /callback?ident_code=<one-time code>// 4. /callback (server side, hl:fetch) POSTs { key, secret, code } to// <ident>/api/exchange and gets the app-specific identity id — nothing else.// This app keeps its OWN users keyed by that id (CONCEPT.md "In the app").// Piece 3 (#26): THE IDENTITY SELECTOR on the same page. `/` includes <ident>/selector.js// and <ident-selector key="<api key>">; choosing an identity fires `ident-login` with a// one-time code, the page POSTs it to /selector-login, this server exchanges it exactly as// /callback does, and the page switches to "logged in" WITHOUT A RELOAD and sets the// selector's `logged-in`. "Log out" (POST /logout) resets the selector.// Both logins give the browser this app's OWN session cookie (`testapp<port>sid`, cookies// ignore ports); `/` renders `logged-in` on the selector when that session is logged in —// the host tells the selector, the selector keeps no state of its own.// A REAL APP NEVER SHOWS THE IDENTITY ID (CONCEPT.md); this test page does, to prove the flow.//// Run: cd testapp && ../bin/hybriel project.hl// TESTAPP_PORT (8354), TESTAPP_URL (origin the browser reaches this app at,// default http://127.0.0.1:<port>), IDENT_URL (http://127.0.0.1:8351),// TESTAPP_STORE (./storage/testapp.json: key, secret, users, sessions).import WebFramework from 'hl:web'import { Response } from 'hl:http1'import { fetch } from 'hl:fetch'import { env } from 'hl:proc'import { readFile, writeFile, exists } from 'hl:fs'import { randomBytes } from 'hl:crypto'import Styles from './styles.hl'import About from './components/about.hl'appTitle = "ident test app"styles = Stylesport = env('TESTAPP_PORT') != null ? toNumber(env('TESTAPP_PORT')) : 8354selfUrl = env('TESTAPP_URL') != null ? env('TESTAPP_URL') : 'http://127.0.0.1:' + portidentUrl = env('IDENT_URL') != null ? env('IDENT_URL') : 'http://127.0.0.1:8351'storeFile = env('TESTAPP_STORE') != null ? env('TESTAPP_STORE') : './storage/testapp.json'load = () => {if (!exists(storeFile)) { return { key = '' secret = '' users = {} count = 0 sessions = {} } }let d = JSON.parse(readFile(storeFile))if (d.sessions == null) { d.sessions = {} } // a piece-2 storereturn d}// THIS APP'S OWN SESSION: cookie -> identity id (server side only)cookieName = 'testapp' + port + 'sid'sidOf = (req) => {let h = req.headers['cookie']if (h == null) { return null }for (part of h.split(';')) {let t = part.trim()if (t.startsWith(cookieName + '=')) { return t.slice(cookieName.length + 1, t.length) }}return null}userOf = (data, req) => {let sid = sidOf(req)if (sid == null || sid == '') { return null }let identity = data.sessions[sid]if (identity == null) { return null }return data.users[identity]}// the app's user for an exchanged identity id (made on its first login) + a new sessionlogIn = (data, identity) => {let known = data.users[identity]let state = 'welcome back'if (known == null) {data.count = data.count + 1known = { n = data.count }data.users[identity] = knownstate = 'new user'}let sid = randomBytes(16)data.sessions[sid] = identitysave(data)return { state = state n = known.n cookie = cookieName + '=' + sid + '; Path=/; HttpOnly; SameSite=Lax' }}// POST <ident>/api/exchange { key, secret, code } -> { status, j }exchangeCode = (data, code) => {let r = fetch(identUrl + '/api/exchange', { method = 'POST' json = { key = data.key secret = data.secret code = code } timeoutMs = 5000 })let j = r.json()return { status = r.status j = j text = j == null ? r.text() : '' }}save = (data) => { writeFile(storeFile, JSON.stringify(data)) }escape = (s) => { return ('' + s).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"') }// a query value, percent-encoded (encodeURIComponent is a global, hybriel#14)encode = (s) => { return encodeURIComponent('' + s) }page = (status, body) => {let html = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>ident test app</title><style>body{margin:0;padding:1rem;font:16px/1.5 system-ui,sans-serif;color:rgb(195, 200, 205);background:rgb(25, 30, 35)}main{max-width:34rem;margin:0 auto;display:grid;gap:1rem}a{color:#ce9178}h1{margin:0;font-size:1.3rem;color:rgb(245, 250, 255)}p{margin:0}code{overflow-wrap:anywhere;word-break:break-all;color:rgb(245, 250, 255)}form{display:grid;gap:.6rem;padding:1rem;border:1px solid rgb(70, 75, 80);border-radius:.5rem}label{display:grid;gap:.2rem;font-size:.9rem}input{font:inherit;padding:.5rem;color:rgb(245, 250, 255);background:rgb(15, 20, 25);border:1px solid rgb(70, 75, 80);border-radius:.4rem;width:100%;box-sizing:border-box}button,a.button{justify-self:start;display:inline-block;font:inherit;font-weight:600;padding:.6rem 1.1rem;color:rgb(15, 20, 25);background:#ce9178;border:1px solid #ce9178;border-radius:.4rem;cursor:pointer;text-decoration:none}.error{color:#f44747}.ok{color:#4ec9b0}.muted{color:rgb(145, 150, 155);font-size:.9rem}h2{margin:0;font-size:1.05rem;color:rgb(245, 250, 255)}section{display:grid;gap:.6rem;padding:1rem;border:1px solid rgb(70, 75, 80);border-radius:.5rem}button.quiet{color:rgb(195, 200, 205);background:transparent;border-color:rgb(70, 75, 80)}[hidden]{display:none!important}</style></head><body><main><h1>ident test app <span class="muted">' + escape(selfUrl) + '</span></h1>' + body + '<p><a id="home" href="/">start page</a></p></main></body></html>'return new Response(html, { status = status headers = { 'Content-Type' = 'text/html; charset=utf-8' 'Cache-Control' = 'no-store' } })}redirect = (url) => { return new Response('', { status = 302 headers = { 'Location' = url 'Cache-Control' = 'no-store' } }) }setupForm = (data) => {return '<form id="setup" method="post" action="/setup"><p class="muted">Register this app in ident (Apps → Register an app, origin <code>' + escape(selfUrl) + '</code>) and paste its API key and secret here.</p><label>API key<input id="key" name="key" autocomplete="off" value="' + escape(data.key) + '"></label><label>Secret<input id="secret" name="secret" autocomplete="new-password" type="password"></label><button id="savesetup" type="submit">Save</button></form>'}// THE HOST PAGE'S SCRIPT: the selector's code -> this server (/selector-login) -> logged// in without a reload; logout resets the selector. (An hl string has no escapes: the// JavaScript below uses double quotes only.)hostScript = '<script>(() => {' +'const sel = document.getElementById("selector");' +'const state = document.getElementById("loginstate");' +'const out = document.getElementById("selresult");' +'const logout = document.getElementById("logout");' +'sel.addEventListener("ident-login", async (e) => {' +'out.className = "muted"; out.textContent = "exchanging the code …";' +'const r = await fetch("/selector-login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: e.detail.code }) });' +'const j = await r.json().catch(() => ({}));' +'if (!r.ok) { out.className = "error"; out.textContent = "exchange failed (" + r.status + "): " + (j.error || ""); return; }' +'state.className = "ok"; state.textContent = "Logged in: this app’s user #" + j.n + " (" + j.state + ")";' +'out.className = "muted"; out.innerHTML = "";' +'out.append("via the selector, without a reload: ", Object.assign(document.createElement("strong"), { id: "userstate", textContent: j.state }), ", identity id ", Object.assign(document.createElement("code"), { id: "identity", textContent: j.identity }));' +'logout.hidden = false;' +'sel.loggedIn = true;' +'});' +'logout.addEventListener("click", async () => {' +'await fetch("/logout", { method: "POST" });' +'state.className = ""; state.textContent = "Not logged in.";' +'out.textContent = ""; logout.hidden = true;' +'sel.reset();' +'});' +'})();</script>'home = (route, req) => {let data = load()if (data.key == '') { return page(200, '<p id="state">Not set up yet.</p>' + setupForm(data)) }let href = identUrl + '/login?key=' + data.key + '&return=' + encode(selfUrl + '/callback')let user = userOf(data, req)let account = user != null ? '<p id="loginstate" class="ok">Logged in: this app’s user #' + user.n + '</p>' : '<p id="loginstate">Not logged in.</p>'let logoutButton = '<button id="logout" type="button" class="quiet"' + (user != null ? '' : ' hidden') + '>Log out</button>'let selector = '<ident-selector id="selector" key="' + escape(data.key) + '"' + (user != null ? ' logged-in' : '') + '></ident-selector>'return page(200, '<p id="state">Set up with API key <code id="setupkey">' + escape(data.key) + '</code>. Users so far: <span id="usercount">' + data.count + '</span>.</p>' +'<section id="account">' + account + logoutButton + '</section>' +'<section id="selectorbox"><h2>Log in with the identity selector</h2>' + selector + '<p id="selresult"></p></section>' +'<p><a id="toinvites" href="/invites">Invites</a></p><section id="buttonbox"><h2>… or with the login button</h2><p><a id="login" class="button" href="' + escape(href) + '">Log in with ident</a></p></section>' +setupForm(data) +'<script src="' + escape(identUrl) + '/selector.js"></script>' + hostScript)}// the key and the secret: plain ASCII (pk_/sk_ + hex), so the form body needs no decodingokToken = (v, prefix, n) => {if (v == null || v.length != prefix.length + n || v.slice(0, prefix.length) != prefix) { return false }let i = prefix.lengthwhile (i < v.length) {if (!'0123456789abcdef'.includes(v[i])) { return false }i = i + 1}return true}setup = (route, req) => {if (req.method != 'POST') { return redirect('/') }let f = {}for (pair of ('' + req.body).split('&')) {let eq = pair.indexOf('=')if (eq > 0) { f[pair.slice(0, eq)] = pair.slice(eq + 1, pair.length).trim() }}if (!okToken(f.key, 'pk_', 32) || !okToken(f.secret, 'sk_', 48)) { return page(400, '<p id="result" class="error">That is not an ident API key (pk_…) and secret (sk_…).</p>') }let data = load()data.key = f.keydata.secret = f.secretsave(data)return redirect('/')}callback = (route, req) => {let q = req.query != null ? req.query : {}let code = q.ident_codeif (code == null || code == '') { return page(400, '<p id="result" class="error">no ident_code on the callback</p>') }let data = load()let x = exchangeCode(data, code)let j = x.jif (x.status != 200 || j == null || j.identity == null) {return page(400, '<p id="result" class="error">exchange failed (<span id="status">' + x.status + '</span>): <span id="error">' + escape(j != null ? j.error : x.text) + '</span></p>')}// THIS APP'S OWN USER, connected to the identity id (server side)let u = logIn(data, j.identity)// an invite came along: ask ident which identity accepted it (it must be the one just exchanged)let inv = ''if (q.invite != null && q.invite != '') {let g = identPost('/api/invites/get', data, { id = q.invite })if (g.status == 200) {inv = '<p id="invite">Invite <strong id="invitestate">' + escape(g.j.invite.state) + '</strong> for <span id="inviteproject">' + escape(g.j.invite.project) + '</span> as <span id="inviterole">' + escape(g.j.invite.role) + '</span>; accepted by <span id="invitewho">' + (g.j.invite.identities.includes(j.identity) ? 'this identity' : 'someone else') + '</span>.</p>'} else { inv = '<p id="invite" class="error">invite lookup failed (' + g.status + ')</p>' }}let res = page(200, inv + '<p id="result" class="ok">Logged in: <strong id="userstate">' + u.state + '</strong>, this app’s user #<span id="usernumber">' + u.n + '</span>.</p><p class="muted">ident answered only <code id="answer">' + escape(JSON.stringify(j)) + '</code> — the identity id for THIS app (a real app never shows it).</p><p>identity id: <code id="identity">' + escape(j.identity) + '</code></p>')res.headers['Set-Cookie'] = u.cookiereturn res}json = (status, value) => { return new Response(JSON.stringify(value), { status = status headers = { 'Content-Type' = 'application/json; charset=utf-8' 'Cache-Control' = 'no-store' } }) }// POST /selector-login {"code":"<hex>"} — the selector's one-time code, from this app's own// page: exchanged server side like /callback; answers { state, n, identity } + the cookieselectorLogin = (route, req) => {if (req.method != 'POST') { return json(405, { error = 'POST only' }) }let b = ('' + req.body).trim()let pre = '{"code":"'if (!b.startsWith(pre) || !b.endsWith('"}')) { return json(400, { error = 'the body must be {"code":"…"}' }) }let code = b.slice(pre.length, b.length - 2)if (!okToken(code, '', code.length) || code == '') { return json(400, { error = 'the code must be hex' }) }let data = load()let x = exchangeCode(data, code)let j = x.jif (x.status != 200 || j == null || j.identity == null) { return json(400, { error = 'exchange failed (' + x.status + '): ' + (j != null ? j.error : x.text) }) }let u = logIn(data, j.identity)let res = json(200, { state = u.state n = u.n identity = j.identity })res.headers['Set-Cookie'] = u.cookiereturn res}// POST /logout — ends this app's session (ident's own session is untouched)logout = (route, req) => {if (req.method != 'POST') { return json(405, { error = 'POST only' }) }let data = load()let sid = sidOf(req)if (sid != null && data.sessions[sid] != null) {let rest = {}for (k of data.sessions.keys()) { if (k != sid) { rest[k] = data.sessions[k] } }data.sessions = restsave(data)}let res = json(200, { loggedOut = true })res.headers['Set-Cookie'] = cookieName + '=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'return res}// ---- INVITES (ident#22): this app invites people through ident ---------------------------// GET /invites lists this app's invites (ident: POST /api/invites/list) and has a form that// creates one (POST /api/invites; with an address ident mails the link, through its own// mailer). "Share" is a mailto: link (no script). The link's person lands on /callback with// `ident_code` AND `invite`; /callback then asks ident which identity accepted the invite.identPost = (path, data, payload) => {let body = { key = data.key secret = data.secret }for (k of payload.keys()) { body[k] = payload[k] }let r = fetch(identUrl + path, { method = 'POST' json = body timeoutMs = 5000 })let j = r.json()return { status = r.status j = j text = j == null ? r.text() : '' }}formOf = (req) => {let f = {}for (pair of ('' + req.body).split('&')) {let eq = pair.indexOf('=')if (eq > 0) { f[pair.slice(0, eq)] = pair.slice(eq + 1, pair.length).trim().replaceAll('%40', '@').replaceAll('%2B', '+') }}return f}invitesPage = (data, top) => {let x = identPost('/api/invites/list', data, {})let rows = ''if (x.status == 200) {for (v of x.j.invites) {rows = rows + '<li class="invite"><strong>' + escape(v.project) + '</strong> as ' + escape(v.role) + ' — <span class="state">' + escape(v.state) + '</span>, used ' + v.used + ' of ' + v.uses + (v.identities.length > 0 ? ', identity <code class="who">' + escape(v.identities.join(' ')) + '</code>' : '') + (v.state == 'open' ? '<form method="post" action="/invites/revoke"><input type="hidden" name="id" value="' + escape(v.id) + '"><button class="revoke quiet" type="submit">Revoke</button></form>' : '') + '</li>'}} else { rows = '<li class="error">list failed (' + x.status + '): ' + escape(x.j != null ? x.j.error : x.text) + '</li>' }return page(200, top + '<section><h2>Invite someone</h2><form id="inviteform" method="post" action="/invites/create"><label>Project<input id="iproject" name="project" value="demo"></label><label>Role<input id="irole" name="role" value="member"></label><label>Email (optional — ident mails the link)<input id="iemail" name="email" type="email"></label><label>Uses<input id="iuses" name="uses" value="1"></label><label>Days<input id="idays" name="days" value="7"></label><button id="create" type="submit">Create invite</button></form></section><section><h2>Invites</h2><ul id="invites">' + rows + '</ul></section>')}invites = (route, req) => {let data = load()if (data.key == '') { return redirect('/') }return invitesPage(data, '')}invitesCreate = (route, req) => {if (req.method != 'POST') { return redirect('/invites') }let data = load()let f = formOf(req)let payload = { project = f.project role = f.role 'return' = selfUrl + '/callback' }if (f.email != null && f.email != '') { payload.email = f.email }if (f.uses != null && f.uses != '') { payload.uses = toNumber(f.uses) }if (f.days != null && f.days != '') { payload.days = toNumber(f.days) }let x = identPost('/api/invites', data, payload)if (x.status != 200) { return invitesPage(data, '<p id="result" class="error">create failed (' + x.status + '): ' + escape(x.j != null ? x.j.error : x.text) + '</p>') }let share = 'mailto:?subject=' + encode('Invitation to ' + x.j.project) + '&body=' + encode(x.j.url)return invitesPage(data, '<section id="created"><p class="ok">Invite made' + (x.j.mailed ? ' and mailed by ident' : '') + '.</p><input id="link" readonly value="' + escape(x.j.url) + '"><p><a id="share" class="button" href="' + escape(share) + '">Share by mail</a></p></section>')}invitesRevoke = (route, req) => {if (req.method != 'POST') { return redirect('/invites') }let data = load()let f = formOf(req)identPost('/api/invites/revoke', data, { id = f.id })return redirect('/invites')}routes = [{ pattern = "/favicon.ico" direct = "" }{ pattern = "/invites" function = invites }{ pattern = "/invites/create" function = invitesCreate }{ pattern = "/invites/revoke" function = invitesRevoke }{ pattern = "/setup" function = setup }{ pattern = "/callback" function = callback }{ pattern = "/selector-login" function = selectorLogin }{ pattern = "/logout" function = logout }{ pattern = "/about" component = About }{ pattern = "/" function = home }]sessionDir = falseserver = new WebFramework(routes = routes, styles = styles, port = port)
Branches
- mainmain branch
Latest commits
- 5fdbb6b2ident mission 009: report — scratch folder notemre
- 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