Files
zooqueen e937ba8c71 a new account is met by a card naming what is here
Four lines on the empty chat: the model picker, the modes and their sources,
/agent, and the sidebar. It dims nothing and gates nothing -- the arrival modal
was removed by owner call, and this sits beside the product rather than in
front of it.

`toured` carries no schema default. `false` shows the card, `true` has seen it,
and ABSENT is an account older than the card, which never gets one; a default
reaches every record that lacks the field, which is every account that already
exists. `reconcileUser` writes `false` at creation, so exactly the accounts born
from here on qualify. The client tests `=== false` for the same reason, and the
spec fails when that is weakened to a falsy check.

It rides out on `GET /v1/chat/user`, which already sends the projected document,
so there is no second GET to keep in step -- one POST records the dismissal, and
the cached user is written on mutate so the card goes on the click rather than a
round-trip later.

In the flow, under the composer, not floating over it. The composer block is
min-h-[280px] at the bottom of that column and its input is capped and centred,
leaving ~142px of gutter at 1440 against a card that needs 304 -- measured in
Chromium against the built stylesheet: 288x252 at 16px from the column edge,
clear of the composer at 1440 and at 390, no sideways scroll, 17.25:1 on the
title and 12.82:1 on the lines.
2026-08-17 16:38:35 -07:00

261 lines
9.4 KiB
JavaScript

const { logger } = require('@hanzochat/data-schemas');
const { SystemRoles } = require('@hanzochat/data-provider');
const { findOpenIDUser, getBalanceConfig } = require('@hanzochat/api');
const { getAppConfig } = require('~/server/services/Config');
const { findUser, createUser, updateUser, countUsers } = require('~/models');
/**
* The local record standing behind a verified Hanzo IAM identity.
*
* IAM says who someone is; this says what chat knows about them — their
* conversations, balance, role, preferences. The record is a projection of the
* identity, never a second source of it: it is keyed by the IAM subject and
* every field it copies comes from verified claims.
*
* It is reconciled on the request itself, so a person who has just been given
* access at hanzo.id can use chat immediately, with no enrolment step in
* between.
*/
/** Coerce a claim to a string, else ''. */
function claimStr(value) {
return typeof value === 'string' ? value : '';
}
/**
* The profile photo, from the ONE place hanzo.id actually publishes it.
*
* It is NOT in the id_token. Measured against production: a live token carries
* iss/sub/aud/exp/nbf/iat/jti/scope/owner/organization/email/name/
* preferred_username/displayName/billing_account/azp/tokenType/orgs — and no
* `picture`. IAM's userinfo endpoint DOES publish it
* (`internal/oidc/userinfo.go`: `putIf(info, "picture", u.Avatar)`), and its
* discovery document advertises `picture` in `claims_supported`, which is what
* made the token look like the place to read it.
*
* Read when the account is created, and otherwise only from the one endpoint
* that renders a person's identity. Identification now happens on EVERY request,
* and this is a network round trip: asking here for anyone whose photo is
* missing would put two hops in front of every API call they ever make, forever,
* to be told there is still no photo.
*
* Best-effort by construction: nobody should be locked out because a photo
* could not be fetched, so every error resolves to '' and the caller falls back
* to the initials it already draws.
*/
async function pictureFromUserinfo(accessToken) {
const issuer = (process.env.OPENID_ISSUER || '').replace(/\/+$/, '');
if (!accessToken || !issuer) {
return '';
}
try {
// ASK DISCOVERY WHERE USERINFO IS. Guessing the path costs a silent failure:
// `/v1/iam/userinfo` looks right and 404s — the real endpoint is
// `/v1/iam/oauth/userinfo`. Since every error here resolves to '', a wrong
// path would have been invisible forever, which is the worst way to be
// wrong. The issuer publishes the answer, so read it.
const meta = await fetch(`${issuer}/.well-known/openid-configuration`, {
signal: AbortSignal.timeout(5000),
});
if (!meta.ok) {
logger.debug(`[iamUser] discovery ${meta.status} — no photo`);
return '';
}
const endpoint = claimStr((await meta.json())?.userinfo_endpoint);
if (!endpoint) {
logger.debug('[iamUser] issuer publishes no userinfo_endpoint — no photo');
return '';
}
const res = await fetch(endpoint, {
headers: { Authorization: `Bearer ${accessToken}` },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
logger.debug(`[iamUser] userinfo ${res.status} — no photo`);
return '';
}
return claimStr((await res.json())?.picture);
} catch (err) {
logger.debug('[iamUser] userinfo unreachable — no photo:', err?.message || err);
return '';
}
}
/** Best-effort display name from OIDC claims. */
function fullNameFromClaims(claims) {
if (claimStr(claims.name)) {
return claims.name;
}
const parts = [claims.given_name, claims.family_name].filter((p) => typeof p === 'string' && p);
if (parts.length) {
return parts.join(' ');
}
return claimStr(claims.preferred_username) || claimStr(claims.email) || '';
}
/**
* What the claims say this record should hold, limited to the fields IAM owns.
* @param {Record<string, any>} claims
*/
function projection(claims) {
return {
name: fullNameFromClaims(claims),
username:
claimStr(claims.preferred_username) ||
claimStr(claims.username) ||
(claimStr(claims.email) ? claims.email.split('@')[0] : claimStr(claims.sub)),
organization:
claimStr(claims.owner) || claimStr(claims.organization) || claimStr(claims.org) || '',
project: claimStr(claims.project) || '',
groups: Array.isArray(claims.groups) ? claims.groups : [],
};
}
/**
* Only the fields that actually differ.
*
* Identification runs per request now, so writing the projection unconditionally
* would be a database write on every call. Nothing here changes between two
* requests carrying the same token, so in the steady state this is empty and
* the reconcile is a single read.
*
* @param {import('@hanzochat/data-schemas').IUser} user
* @param {Record<string, any>} claims
* @param {string} openidId
*/
function changes(user, claims, openidId) {
const update = {};
const next = projection(claims);
if (user.provider !== 'openid') {
update.provider = 'openid';
}
if (user.openidId !== openidId) {
update.openidId = openidId;
}
if (next.name && next.name !== user.name) {
update.name = next.name;
}
if (next.username && next.username !== user.username) {
update.username = next.username;
}
if (next.organization && next.organization !== user.organization) {
update.organization = next.organization;
}
if (next.project && next.project !== user.project) {
update.project = next.project;
}
if (next.groups.length && next.groups.join() !== (user.groups || []).join()) {
update.groups = next.groups;
}
if (!user.role) {
update.role = SystemRoles.USER;
}
return update;
}
/**
* Find — or on a first sign-in create — the local record for a verified IAM
* identity.
*
* @param {Record<string, any>} claims - verified IAM token claims
* @param {string} [accessToken] - the caller's token, for the one userinfo read
* @returns {Promise<import('@hanzochat/data-schemas').IUser>}
*/
async function reconcile(claims, accessToken) {
const openidId = claimStr(claims.sub);
const email = claimStr(claims.email);
const { user: found, error } = await findOpenIDUser({
findUser,
openidId,
email: email || undefined,
idOnTheSource: claimStr(claims.oid) || undefined,
strategyName: 'iam',
});
if (error) {
const err = new Error(error);
err.status = 403;
throw err;
}
if (found) {
const update = changes(found, claims, openidId);
// A manually-set avatar (`manual=true`) is the user's own choice and is
// never overwritten by the one from the identity provider.
const picture = claimStr(claims.picture);
if (picture && !found.avatar) {
update.avatar = picture;
}
if (Object.keys(update).length === 0) {
return found;
}
return (await updateUser(found._id, update)) || found;
}
const appConfig = await getAppConfig();
const isFirstRegisteredUser = (await countUsers()) === 0;
try {
return await createUser(
{
provider: 'openid',
openidId,
email: email || '',
emailVerified: claims.email_verified === true,
avatar: claimStr(claims.picture) || (await pictureFromUserinfo(accessToken)),
idOnTheSource: claimStr(claims.oid),
role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER,
// The account is new, so the welcome card has not been shown. Written
// here rather than defaulted on the schema: a default applies to every
// record that lacks the field, which is every account that already
// exists. See the field's own note.
toured: false,
...projection(claims),
},
getBalanceConfig(appConfig),
true,
true,
);
} catch (err) {
// An identity has exactly one record, and the unique index is what settles
// who writes it. Whoever does not write reads what was written — the record
// is the outcome either way, so losing the insert is not losing the session.
const written = await findUser({ openidId });
if (written) {
return written;
}
throw err;
}
}
/** Reconciles running right now, by IAM subject. See `reconcileUser` below. */
const pending = new Map();
/**
* One reconcile in flight per identity.
*
* A first sign-in arrives as a burst — the app opens with a dozen authenticated
* requests at once, all carrying the same freshly minted token, none of which
* has a record to read yet. Sharing one reconcile creates the record once and
* lets every request in the burst read it, so which request happens to arrive
* first stops deciding whether the visitor is signed in.
*
* Keyed by the IAM subject and dropped the moment it settles: this holds the
* identity's reconcile WHILE IT RUNS, and is never a cache of its answer.
*
* @param {Record<string, any>} claims - verified IAM token claims
* @param {string} [accessToken] - the caller's token, for the one userinfo read
* @returns {Promise<import('@hanzochat/data-schemas').IUser>}
*/
function reconcileUser(claims, accessToken) {
const subject = claimStr(claims.sub);
const running = pending.get(subject);
if (running) {
return running;
}
const settling = reconcile(claims, accessToken).finally(() => pending.delete(subject));
pending.set(subject, settling);
return settling;
}
module.exports = { reconcileUser, pictureFromUserinfo, claimStr, fullNameFromClaims };