Files
chat/api/server/routes/askMessage.js
zooqueen 3c15370f7d Verify the caller's Hanzo IAM token instead of issuing a session
Chat was minting its own credentials on top of an identity IAM had already
established: the browser ran the PKCE flow, posted the resulting IAM token to a
bridge, and got back a Chat JWT, a refresh cookie and a Session row. Everything
after login was authenticated by that second credential, and IAM's was filed
away server-side for calls to cloud.

The token IAM issues is now the only one. It rides every request as the bearer,
is verified against IAM's JWKS on the spot, and is not stored — so there is one
credential, and the principal that authenticates here is by construction the
principal cloud bills. `resolveTenantBearer` loses the machinery that existed to
keep a stored copy honest (principal binding, expiry checks, cookie fallback,
the renewal middleware in front of the completion route); a token that IS the
request cannot name a different user or outlive the request it arrived on.

The audience is now checked. One issuer serves every Hanzo relying party, so a
token addressed to another app would otherwise authenticate here on the strength
of a shared signature.

Removed, because IAM owns them: two-factor enrolment and verification, email
verification, password reset, the admin panel's OAuth code exchange, and the
server-initiated OpenID redirect. Sign-out now ends the session where it lives —
revoking both tokens at the issuer — rather than dropping the local copy and
leaving the browser recognised.

A guest holds nothing at all. Anonymous chat used to mint a signed guest token,
which proved nothing: minting was unauthenticated and the free-message quota
keyed on the client address regardless. A request without a bearer is now the
guest, identified by that same address, hashed so it stays out of job metadata
and logs.
2026-08-14 14:25:01 -07:00

68 lines
2.9 KiB
JavaScript

/**
* The one honest sentence for an upstream refusal on the answer engine.
*
* Its own module because it is POLICY, not transport: which refusal gets which
* words, and — the part that matters — which refusal earns the client's "Sign in"
* button. The route pulls in the TS/ESM cloud client, so keeping this here also
* makes it testable without loading any of that.
*/
/** The client renders a sign-in action for exactly this code. */
const SIGNIN_REQUIRED = 'ASK_SIGNIN_REQUIRED';
/**
* @param {number} status upstream status, already narrowed by relayStatus
* @param {boolean} signedIn the caller has a real session (not anonymous, not guest)
*/
function upstreamMessage(status, signedIn) {
// 401 and 403 are NOT the same refusal: one is "we do not know who you are any
// more", the other is "we know, and you may not". They get different words and,
// below, different actions.
if (status === 401 && !signedIn) {
return 'Sign in with Hanzo to search — your Hanzo account funds this request.';
}
// A 401 for a caller who still HOLDS a token means IAM refused it, and the
// browser has already tried to renew it with IAM's refresh grant before the
// request was ever sent. So nothing on this side can recover it and the only
// cure is a new sign-in.
//
// This used to say "reload the page and try again", from a time when there was no
// durable refresh and a reload genuinely re-authenticated from scratch. Once
// renewal landed, that sentence became advice that cannot work: reloading replays
// a session whose refresh credential has just been discarded, so the user reloads
// forever. The words follow the mechanism.
if (status === 401) {
return 'Your Hanzo sign-in has expired — sign in again to continue.';
}
if (status === 403) {
return 'Your Hanzo account is not permitted to run this request.';
}
if (status === 402) {
return 'This search is not covered by your current balance.';
}
if (status === 429) {
return 'Too many searches right now — try again in a moment.';
}
return 'The answer engine is unavailable right now.';
}
/**
* Whether this refusal is one that signing in would actually resolve — THE policy,
* and the only place it is decided. The route used to hand-roll this condition at
* each refusal while this function sat exported beside them, which is how the two
* spellings drifted apart.
*
* A 401 always earns the button: the credential is gone and renewal has already
* failed, whether or not a browser session remains. A 403 is about permission, so it
* earns the button only for a caller who has not signed in yet — signing in again
* cannot grant a permission the account does not have.
*/
function needsSignIn(status, signedIn) {
if (status === 401) {
return true;
}
return status === 403 && !signedIn;
}
module.exports = { upstreamMessage, needsSignIn, SIGNIN_REQUIRED };