// Sessions — in memory, and deliberately nothing more.
//
// ── What a session is and is not ──────────────────────────────────────────────────────────────
//
// It maps an opaque cookie value to the browser public key that proved itself at login. That is
// ALL. It is not a credential, it carries no permissions, and it is never trusted on its own:
// every request re-asks the daemon whether that key is still allowed (`browser-status`).
//
// ⛔ So a revoke lands on the NEXT request, not when something lapses. That is the whole reason
// there is no expiry to configure — the check is live, so an expiry would only add a way to be
// logged out for no reason.
//
// ── Why in memory ─────────────────────────────────────────────────────────────────────────────
//
// The surface holds no storage — that is its boundary, and its test enforces it. So sessions die
// with the process, and a restart means logging in again. That is honest and stated rather than
// worked around: persisting them would make the renderer own durable security state, which is the
// thing it exists not to do.
//
// ⚠ The cookie value is 32 random bytes. It is a bearer token for the length of one process, so it
// is HttpOnly and SameSite=Strict — a page cannot read it, and it does not ride cross-site
// requests. It is not signed, because nothing verifies it but this map.
// ⚠ bare-crypto — NOT the storage layer's crypto package. The surface must not reach into the
// replication stack, and test/boundary.test.js caught the first version of this import. A renderer
// needs random bytes, nothing more.
//
// That test greps source TEXT, so naming the offending package here would trip it too; the guard is
// blunt on purpose and the comment moves instead.
import crypto from 'bare-crypto'
export const COOKIE = 'favela_session'
export class Sessions {
constructor () {
this._byId = new Map() // sessionId -> publicKey
this._handoff = new Map() // one-time token -> sessionId
}
/**
* A ONE-TIME token that can be exchanged for this session's cookie.
*
* ⛔ Exists because the session cookie is `HttpOnly`, so it cannot be handed to an embedded
* WebView from the outside — `document.cookie` cannot write one and no native API portably can.
* The cookie has to arrive on an HTTP response, which means a route. favela-web reached the same
* conclusion and for the same reason.
*
* ⛔ ONE-TIME, unlike favela-web's, which accepts a token repeatedly and leans on the request
* coming from loopback. On a desktop that is a real gate; on Android it is not — every app on the
* device shares loopback, so a token that survives its first use is a replayable credential
* sitting in a URL. Burning it on redemption costs one `delete`.
*/
createHandoff (id) {
if (!id || !this._byId.has(id)) return null
const token = crypto.randomBytes(32).toString('hex')
this._handoff.set(token, id)
return token
}
/** Exchange a handoff token for its session id, consuming it. Null if unknown or already used. */
redeemHandoff (token) {
if (!token) return null
const id = this._handoff.get(token)
if (!id) return null
this._handoff.delete(token)
// ⚠ The session may have been destroyed between minting and redeeming — a revoke, or a restart.
return this._byId.has(id) ? id : null
}
/** Start a session for a browser that has just proven its key. */
create (publicKey) {
const id = crypto.randomBytes(32).toString('hex')
this._byId.set(id, publicKey)
return id
}
/** The public key behind a cookie, or null. */
keyFor (id) {
if (!id) return null
return this._byId.get(id) || null
}
destroy (id) {
if (!id) return
this._byId.delete(id)
for (const [t, sid] of this._handoff) if (sid === id) this._handoff.delete(t)
}
/** Every session for a key — a revoked browser should not keep a second tab alive. */
destroyKey (publicKey) {
for (const [id, key] of this._byId) if (key === publicKey) this.destroy(id)
}
get size () { return this._byId.size }
}
/**
* The session id from a Cookie header.
*
* ⚠ Parsed strictly: a cookie value is taken up to the first `;` and never unescaped, because the
* only thing it is ever compared against is a hex string this process generated.
*/
export function sessionIdFrom (cookieHeader) {
if (typeof cookieHeader !== 'string') return null
for (const part of cookieHeader.split(';')) {
const i = part.indexOf('=')
if (i === -1) continue
if (part.slice(0, i).trim() !== COOKIE) continue
const v = part.slice(i + 1).trim()
return /^[0-9a-f]{64}$/.test(v) ? v : null
}
return null
}
export const setCookie = (id) =>
COOKIE + '=' + id + '; Path=/; HttpOnly; SameSite=Strict'
export const clearCookie = () =>
COOKIE + '=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0'
export default { Sessions, sessionIdFrom, setCookie, clearCookie, COOKIE }