mirror of
https://github.com/hanzoai/chat.git
synced 2026-09-30 20:51:17 +00:00
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.
98 lines
3.5 KiB
JavaScript
98 lines
3.5 KiB
JavaScript
/**
|
|
* What of a user record a browser may see.
|
|
*
|
|
* This is an ALLOW-LIST, and that is the whole point. Both places that hand a
|
|
* user to the browser used to name what to withhold — the jwt strategy projects
|
|
* `-password -__v -totpSecret -backupCodes` and the controller deletes three of
|
|
* the same — and a deny-list is only ever correct for the fields somebody
|
|
* thought of. Everything else ships by default, including a field added to the
|
|
* schema tomorrow by someone who never read this file.
|
|
*
|
|
* That is not hypothetical. `refreshToken` is `[SessionSchema]` on the user
|
|
* schema with no `select: false` beside it — unlike `backupCodes`, which has
|
|
* one — so `GET /v1/chat/user` and every `/auth/refresh` answered with the
|
|
* visitor's own refresh tokens as JSON. They already hold one, in an httpOnly
|
|
* cookie; the point of httpOnly is that script cannot read it. Putting the same
|
|
* credential in a response body hands it to any injected script, which turns
|
|
* "act as this person while the tab is open" into "keep acting as them, from
|
|
* anywhere, until the token expires". The severity is the reachability, not the
|
|
* secrecy.
|
|
*
|
|
* So the question this module answers is the opposite one, and it is the only
|
|
* question that closes the class: not "what must we remember to strip" but
|
|
* "what has anyone actually asked for".
|
|
*
|
|
* WITHHELD, and why each is safe to withhold: `refreshToken` (above);
|
|
* `totpSecret` / `backupCodes` (already `select: false`, listed so the intent
|
|
* survives a schema edit); `__v` (mongoose's own counter); and the external
|
|
* identity ids `googleId`/`facebookId`/`samlId`/`ldapId`/`githubId`/
|
|
* `discordId`/`appleId`/`idOnTheSource`, which nothing in the client reads —
|
|
* verified by grep, not assumed.
|
|
*
|
|
* `openidId` IS sent, deliberately, and a test asserting otherwise is wrong:
|
|
* `Providers/AnalyticsProvider` reads it as the IAM `sub`, the one id that
|
|
* correlates a person across hanzo.ai, hanzo.app and here. Chat's own `id` is a
|
|
* different value and cannot stand in for it.
|
|
*/
|
|
|
|
/** Every field any consumer has actually asked for. Adding one is a decision. */
|
|
const PUBLIC = [
|
|
// identity
|
|
'id',
|
|
'_id',
|
|
'name',
|
|
'username',
|
|
'email',
|
|
'emailVerified',
|
|
'avatar',
|
|
'provider',
|
|
'role',
|
|
// the IAM subject — cross-surface analytics identity, NOT chat's own row id
|
|
'openidId',
|
|
// tenancy: the org switcher in Nav/AccountSettings renders from these
|
|
'organization',
|
|
'organizationTitle',
|
|
'organizationTag',
|
|
'project',
|
|
'groups',
|
|
'tenantId',
|
|
// preferences and state the app renders
|
|
'plugins',
|
|
'twoFactorEnabled',
|
|
'termsAccepted',
|
|
'toured',
|
|
'personalization',
|
|
'favorites',
|
|
'skillStates',
|
|
// lifecycle
|
|
'createdAt',
|
|
'updatedAt',
|
|
'expiresAt',
|
|
];
|
|
|
|
/**
|
|
* Project a user record onto what a browser may see.
|
|
*
|
|
* Absent keys are omitted rather than sent as `undefined`, so the response
|
|
* carries no evidence of a field that was not set — and a caller can still tell
|
|
* "no avatar" from "avatar: null" if it ever needs to.
|
|
*
|
|
* @param {object|null|undefined} user a user document or plain object
|
|
* @returns {object|null|undefined} the same value when there is nothing to project
|
|
*/
|
|
function publicUser(user) {
|
|
if (user == null) {
|
|
return user;
|
|
}
|
|
const src = typeof user.toObject === 'function' ? user.toObject() : user;
|
|
const out = {};
|
|
for (const key of PUBLIC) {
|
|
if (src[key] !== undefined) {
|
|
out[key] = src[key];
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
module.exports = { publicUser, PUBLIC };
|