Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/site-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@comtegra/docs-chat-client": minor
"@comtegra/docusaurus-theme-docs-chat": minor
---

Token strony dla dokumentacji niepublicznej: `streamDocsChat` przyjmuje `headers`, paczka eksportuje `SITE_TOKEN_HEADER` i `DocsChatRequestError` (ze statusem HTTP), reducer zna notice `unauthorized`; theme dostaje opcję `siteToken` (nagłówek `X-Site-Token` w czacie i feedbacku, osobny komunikat przy 401/403). Schemat statusu: pole `auth.siteToken`.
2 changes: 2 additions & 0 deletions fixtures/site/docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ module.exports = {
assistantName: "e-Doradca",
examplePrompts: ["Jak założyć sprawę?", "Jak dodać załącznik?"],
clientIdQueryParam: "r",
// tenant niepubliczny: token strony z env buildu (fixture: opcjonalny)
siteToken: process.env.FIXTURE_SITE_TOKEN || null,
},
],
],
Expand Down
19 changes: 19 additions & 0 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,25 @@ zawsze kończy `done` albo `error` — ucięty strumień traktuj jak błąd (tra
ciebie). Schematy: `schemas/*.schema.json` (JSON Schema 2020-12) — te same pliki konsumują
testy kontraktowe backendu.

## Token strony (dokumentacja niepubliczna)

Tenant, którego dokumentacja nie jest publiczna, wymaga w czacie i feedbacku nagłówka
`X-Site-Token` (`GET /status` → `auth.siteToken: true`). Token jest wspólny dla całej
strony (wypiekany w build), nie sekretem użytkownika — chroni treść przed skryptami spoza
strony, nie zastępuje limitów. Bez/zły token = HTTP 401 zamiast strumienia; transport rzuca
wtedy `DocsChatRequestError` ze `status`, a reducer zna notice `unauthorized`:

```js
import { streamDocsChat, SITE_TOKEN_HEADER, DocsChatRequestError, noticeForErrorCode } from "@comtegra/docs-chat-client";

try {
await streamDocsChat({ apiUrl, body, signal, onEvent, headers: { [SITE_TOKEN_HEADER]: token } });
} catch (error) {
const code = error instanceof DocsChatRequestError && (error.status === 401 || error.status === 403) ? "unauthorized" : "llm_error";
onEvent({ type: "error", code, at: Date.now() }); // noticeForErrorCode(code) → "unauthorized" | "error"
}
```

## Użycie bez bundlera (np. Angular.js)

Tarball zawiera build IIFE (`dist/docs-chat-client.iife.min.js`) z globalem
Expand Down
6 changes: 6 additions & 0 deletions packages/client/schemas/status-response.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
"model": { "type": "string" },
"locales": { "type": "array", "items": { "type": "string" } },
"observability": { "type": "boolean" },
"auth": {
"type": "object",
"description": "Dostęp do czatu/feedbacku tenanta: siteToken=true → nagłówek X-Site-Token obowiązkowy (401 bez niego). Pole addytywne — starsze serwisy go nie wysyłają.",
"required": ["siteToken"],
"properties": { "siteToken": { "type": "boolean" } }
},
"index": {
"type": "array",
"items": {
Expand Down
10 changes: 6 additions & 4 deletions packages/client/src/conversation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//
// Kształt stanu jest PRZYPIĘTY (kontrakt między repo):
// status idle | running | done | stopped | error
// notice null | unsafe | pricing | rate_limited | no_context | timeout | error
// notice null | unsafe | pricing | rate_limited | no_context | unauthorized | timeout | error
// traceId null | string (z ramki `done`; klucz feedbacku)
// steps [{ id, status: running|done|failed, meta? }] w kolejności pojawienia
// sources [] (kształt zależny od źródła: docs — SseSource; web — obiekty /api/vdb/query)
Expand All @@ -17,15 +17,16 @@
// Zdarzenia = ramki SSE (`sources`, `step`, `reasoning`, `content`, `done`, `error`) +
// { type: "start", prompt } — nowe pytanie (reset)
// { type: "abort", reason: "stopped"|"timeout" } — Stop użytkownika / brak aktywności
// `error.code` obejmuje kody serwera (invalid_request | rate_limited | no_context | llm_error)
// i klienckie (unsafe | pricing — klasyfikatory site-chatu; nie są błędami: status "done").
// `error.code` obejmuje kody serwera (invalid_request | rate_limited | no_context | llm_error),
// klienckie (unsafe | pricing — klasyfikatory site-chatu; nie są błędami: status "done")
// i transportowe (unauthorized — HTTP 401/403 zamiast strumienia: brak/zły token strony).
//
// Reguły: przy `error`/`abort` ostatni krok „running" → „failed" (serwer docs też to wysyła
// — reducer jest tu defensywny, żeby web z krokami klienckimi zachowywał się tak samo);
// `sources` NIE jest tworzone na nowo przy ramkach `content` (tożsamość dla React.memo).

/** @typedef {"idle"|"running"|"done"|"stopped"|"error"} ConversationStatus */
/** @typedef {null|"unsafe"|"pricing"|"rate_limited"|"no_context"|"timeout"|"error"} ConversationNotice */
/** @typedef {null|"unsafe"|"pricing"|"rate_limited"|"no_context"|"unauthorized"|"timeout"|"error"} ConversationNotice */
/** @typedef {{ id: string, status: "running"|"done"|"failed", meta?: Record<string, unknown> }} ConversationStep */
/**
* @typedef {{
Expand Down Expand Up @@ -72,6 +73,7 @@ export function noticeForErrorCode(code) {
case "pricing":
case "rate_limited":
case "no_context":
case "unauthorized":
return code;
default:
// `timeout` nie jest kodem błędu serwera — to zdarzenie `abort{reason:"timeout"}`
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Publiczne API paczki: transport strumienia czatu, parser SSE, reducer rozmowy, cytowania [n]
// i sanitizacja linków. Zero zależności; działa w przeglądarce (fetch + ReadableStream) i w Node.
export { createSseParser } from "./sse.mjs";
export { streamDocsChat } from "./transport.mjs";
export { DocsChatRequestError, SITE_TOKEN_HEADER, streamDocsChat } from "./transport.mjs";
export {
initialState,
initialThreadState,
Expand Down
30 changes: 26 additions & 4 deletions packages/client/src/transport.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,51 @@
// „bieżące żądanie" (onEvent = emit). Testowalne w node:test z fałszywym `fetch`/ReadableStream.
import { createSseParser } from "./sse.mjs";

/**
* Nagłówek z tokenem strony: tenant dokumentacji niepublicznej (np. podręcznik za firewallem klienta)
* wymaga go dla czatu i feedbacku — bez/zły token = HTTP 401 (a nie ramka `error`). Token jest wspólny
* dla całej strony (wypiekany w build), nie sekretem użytkownika.
*/
export const SITE_TOKEN_HEADER = "X-Site-Token";

/**
* Odpowiedź HTTP bez strumienia (4xx/5xx albo brak body): `status` pozwala rozróżnić 401/403
* (token strony) od reszty i pokazać użytkownikowi właściwy komunikat.
*/
export class DocsChatRequestError extends Error {
/** @param {number} status */
constructor(status) {
super(`Docs chat request failed (${status})`);
this.name = "DocsChatRequestError";
this.status = status;
}
}

/**
* @param {{
* apiUrl: string,
* body: unknown,
* signal: AbortSignal,
* onEvent: (event: any) => void, // ramki + `error`/`done` z `at`
* onActivity?: () => void, // każdy chunk odpowiedzi (zerowanie timeoutu)
* headers?: Record<string, string>, // nagłówki własne (np. { [SITE_TOKEN_HEADER]: token }); Content-Type zawsze JSON
* fetchImpl?: typeof fetch,
* now?: () => number,
* }} options
* @returns {Promise<void>} rozwiązuje po `done`/`error`; rzuca przy błędzie HTTP/sieci (nie przy abort)
* @returns {Promise<void>} rozwiązuje po `done`/`error`; rzuca przy błędzie HTTP/sieci (nie przy abort):
* DocsChatRequestError (z `status`) dla odpowiedzi bez strumienia, błąd fetch dla sieci
*/
export async function streamDocsChat({ apiUrl, body, signal, onEvent, onActivity = () => {}, fetchImpl = fetch, now = Date.now }) {
export async function streamDocsChat({ apiUrl, body, signal, onEvent, onActivity = () => {}, headers = {}, fetchImpl = fetch, now = Date.now }) {
const response = await fetchImpl(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
onActivity();

if (!response.ok || !response.body) {
throw new Error(`Docs chat request failed (${response.status})`);
throw new DocsChatRequestError(response.status);
}

const reader = response.body.getReader();
Expand Down
52 changes: 52 additions & 0 deletions packages/client/tests/site-token.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Token strony: nagłówki własne przechodzą do fetch, odpowiedź 401/403 kończy się DocsChatRequestError
// ze statusem (widget mapuje na notice `unauthorized`), reducer zna ten kod.
import { test } from "node:test";
import assert from "node:assert/strict";

import { DocsChatRequestError, SITE_TOKEN_HEADER, noticeForErrorCode, reduce, initialState, streamDocsChat } from "../src/index.mjs";

test("streamDocsChat: własne nagłówki trafiają do fetch, Content-Type zostaje JSON", async () => {
const calls = [];
const fetchImpl = async (url, init) => {
calls.push({ url, init });
return {
ok: true,
status: 200,
body: { getReader: () => ({ read: async () => ({ done: true, value: undefined }) }) },
};
};
await streamDocsChat({
apiUrl: "https://example.test/chat",
body: { prompt: "q" },
signal: new AbortController().signal,
onEvent: () => {},
headers: { [SITE_TOKEN_HEADER]: "tok-1", "Content-Type": "text/plain" },
fetchImpl,
});
assert.equal(SITE_TOKEN_HEADER, "X-Site-Token");
assert.deepEqual(calls[0].init.headers, { "X-Site-Token": "tok-1", "Content-Type": "application/json" });
});

test("streamDocsChat: 401 bez strumienia rzuca DocsChatRequestError ze statusem (bez ramek do onEvent)", async () => {
const events = [];
await assert.rejects(
streamDocsChat({
apiUrl: "https://example.test/chat",
body: {},
signal: new AbortController().signal,
onEvent: (event) => events.push(event),
fetchImpl: async () => ({ ok: false, status: 401, body: null }),
}),
(error) => error instanceof DocsChatRequestError && error.status === 401 && /401/.test(error.message)
);
assert.deepEqual(events, []);
});

test("noticeForErrorCode: unauthorized jest osobnym notice, reducer przyjmuje go jak inne błędy", () => {
assert.equal(noticeForErrorCode("unauthorized"), "unauthorized");
assert.equal(noticeForErrorCode("llm_error"), "error");
let state = reduce(initialState, { type: "start", prompt: "q", at: 1 });
state = reduce(state, { type: "error", code: "unauthorized", at: 2 });
assert.equal(state.status, "error");
assert.equal(state.notice, "unauthorized");
});
1 change: 1 addition & 0 deletions packages/docusaurus-theme/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export default {
| `clientIdQueryParam` | string \| null | `null` | Parametr URL z id wdrożenia (np. `"r"` → `?r=urzad-x`); wartość musi pasować do `^[A-Za-z0-9_-]{1,64}$` (base64url), inna jest ignorowana; utrwalana w sessionStorage i dołączana do żądań jako `client` (atrybucja per instalacja, nie auth). |
| `externalLinks` | `"allow"` \| `"text"` | `"allow"` | Linki spoza strony w odpowiedzi modelu: klikalne (`rel=noreferrer`) albo sam tekst z hostem. Dla stron publicznych/rządowych zalecane `"text"` — treść indeksu może próbować podsunąć link. |
| `statusProbe` | boolean | `true` | Przy pierwszym otwarciu panelu `GET /status` (timeout 3 s); gdy API jest odcięte (sieci z allowlistą), użytkownik dostaje komunikat z adresem do odblokowania zamiast wiecznego spinnera. |
| `siteToken` | string \| null | `null` | Token strony dla tenanta dokumentacji **niepublicznej**: wysyłany w nagłówku `X-Site-Token` z czatem i feedbackiem (API bez niego odpowiada 401 → komunikat „brak dostępu”). Wypiekany w build (podaj z sekretu workflow przez env), więc widoczny w JS strony — chroni treść przed skryptami spoza strony, nie jest sekretem użytkownika. Drukowalne ASCII, 16–512 znaków. |

## Tłumaczenia

Expand Down
1 change: 1 addition & 0 deletions packages/docusaurus-theme/locales/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"docsChat.error.rateLimited": "Za dużo zapytań. Spróbuj ponownie później.",
"docsChat.error.noContext": "Na tej stronie nie znaleziono wystarczającego kontekstu.",
"docsChat.error.generic": "Asystent nie może teraz odpowiedzieć.",
"docsChat.error.unauthorized": "Ta wersja podręcznika nie ma dostępu do asystenta. Odśwież stronę lub skontaktuj się z administratorem.",
"docsChat.error.timeout": "Asystent zbyt długo nie odpowiadał. Spróbuj ponownie.",
"docsChat.open": "{assistantName}",
"docsChat.minimize": "Zwiń",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export function MarkdownAnswer({ content, streaming = false, components, turnId
export function errorMessageFor(copy, notice, scope = "all") {
if (!notice) return null;
if (notice === "rate_limited") return copy.rateLimited;
if (notice === "unauthorized") return copy.unauthorized;
if (notice === "no_context") return scope === "page" ? copy.noContext : copy.noContextAll;
if (notice === "timeout") return copy.timeoutError;
// kontrakt: unsafe/pricing kończą turę statusem done (to nie błędy) — treść z backendu
Expand Down
5 changes: 5 additions & 0 deletions packages/docusaurus-theme/src/DocsChat/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export function getCopy({ assistantName, examplePrompts = [] }) {
}),
noContext: translate({ id: "docsChat.error.noContext", message: "No relevant answer was found on this page." }),
genericError: translate({ id: "docsChat.error.generic", message: "The assistant could not answer right now." }),
unauthorized: translate({
id: "docsChat.error.unauthorized",
message: "This copy of the documentation is not authorized to use the assistant. Reload the page or contact your administrator.",
description: "HTTP 401/403 from the chat API: the site token baked into the build is missing or was rotated",
}),
timeoutError: translate({ id: "docsChat.error.timeout", message: "The assistant took too long to respond. Please try again." }),
open: translate({ id: "docsChat.open", message: "{assistantName}" }),
minimize: translate({ id: "docsChat.minimize", message: "Minimize" }),
Expand Down
2 changes: 1 addition & 1 deletion packages/docusaurus-theme/src/DocsChat/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export default function DocsChat({ options, permalink }) {
const pageAvailable = typeof permalink === "string" && permalink.length > 0;
const page = useMemo(() => (pageAvailable ? { permalink } : null), [pageAvailable, permalink]);

const chat = useDocsChat({ apiUrl, feedbackUrl, statusUrl, locale, page, storageKeys: options.storageKeys });
const chat = useDocsChat({ apiUrl, feedbackUrl, statusUrl, locale, page, storageKeys: options.storageKeys, siteToken: options.siteToken });
const {
turns,
loading,
Expand Down
23 changes: 16 additions & 7 deletions packages/docusaurus-theme/src/DocsChat/useDocsChat.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import useIsBrowser from "@docusaurus/useIsBrowser";

import { streamDocsChat } from "@comtegra/docs-chat-client";
import { DocsChatRequestError, SITE_TOKEN_HEADER, streamDocsChat } from "@comtegra/docs-chat-client";
import { initialThreadState, reduceThread, threadSelectors } from "@comtegra/docs-chat-client";
import { normalizePanelMode, normalizeScope } from "./lib/preferences.mjs";
import { createTurnTimers } from "./lib/turnTimers.mjs";
Expand Down Expand Up @@ -77,11 +77,14 @@ function readClientId(key) {
}

/**
* @param {{ apiUrl: string, feedbackUrl?: string, statusUrl?: string|null, locale: string, page: { permalink: string }, storageKeys?: { snapshot: string, clientId: string } }} options
* @param {{ apiUrl: string, feedbackUrl?: string, statusUrl?: string|null, locale: string, page: { permalink: string }, storageKeys?: { snapshot: string, clientId: string }, siteToken?: string|null }} options
* `siteToken` — token strony tenanta niepublicznego (nagłówek X-Site-Token w czacie i feedbacku)
*/
export default function useDocsChat({ apiUrl, feedbackUrl, statusUrl, locale, page, storageKeys }) {
export default function useDocsChat({ apiUrl, feedbackUrl, statusUrl, locale, page, storageKeys, siteToken = null }) {
const snapshotKey = storageKeys?.snapshot;
const clientIdKey = storageKeys?.clientId;
// nagłówki auth dla czatu i feedbacku; sonda /status jest publiczna (bez tokena, bez preflightu)
const authHeaders = siteToken ? { [SITE_TOKEN_HEADER]: siteToken } : {};
const [thread, dispatch] = useReducer(reduceThread, initialThreadState);
// SSR i pierwszy render klienta zawsze z domyślnymi wartościami (brak niezgodności hydratacji);
// odtworzenie z sessionStorage w efekcie po montażu
Expand Down Expand Up @@ -249,6 +252,7 @@ export default function useDocsChat({ apiUrl, feedbackUrl, statusUrl, locale, pa
signal,
onEvent: emit,
onActivity: armTimeout,
headers: authHeaders,
});
if (isCurrent()) setBackendStatus("ok"); // udany strumień = API osiągalne (sonda mogła spaść na cold-starcie)
} catch (error) {
Expand All @@ -257,7 +261,10 @@ export default function useDocsChat({ apiUrl, feedbackUrl, statusUrl, locale, pa
return;
}
console.error("Docs chat error:", error);
emit({ type: "error", code: "llm_error", at: Date.now() });
// 401/403 = brak/zły token strony (build bez EDORADCA_SITE_TOKEN, token zrotowany) — osobny
// komunikat, żeby administrator nie szukał awarii modelu
const unauthorized = error instanceof DocsChatRequestError && (error.status === 401 || error.status === 403);
emit({ type: "error", code: unauthorized ? "unauthorized" : "llm_error", at: Date.now() });
} finally {
// timer tylko dla bieżącego żądania — nowszego nie wolno rozbroić
if (controllerRef.current === controller) {
Expand All @@ -266,7 +273,8 @@ export default function useDocsChat({ apiUrl, feedbackUrl, statusUrl, locale, pa
}
}
},
[apiUrl, locale, page, clientIdKey]
// eslint-disable-next-line react-hooks/exhaustive-deps
[apiUrl, locale, page, clientIdKey, siteToken]
);

// Feedback (kciuk w górę / w dół) → cgc-web /api/chat/feedback po `traceId` z ramki `done`.
Expand All @@ -279,7 +287,7 @@ export default function useDocsChat({ apiUrl, feedbackUrl, statusUrl, locale, pa
try {
const response = await fetch(feedbackUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { ...authHeaders, "Content-Type": "application/json" },
body: JSON.stringify({ traceId, value }),
});
const payload = response.ok ? await response.json().catch(() => ({})) : {};
Expand All @@ -292,7 +300,8 @@ export default function useDocsChat({ apiUrl, feedbackUrl, statusUrl, locale, pa
setFeedbackByTrace((current) => ({ ...current, [traceId]: { value, pending: false, stored: false } }));
}
},
[feedbackUrl]
// eslint-disable-next-line react-hooks/exhaustive-deps
[feedbackUrl, siteToken]
);

const clearConversation = useCallback(() => {
Expand Down
9 changes: 9 additions & 0 deletions packages/docusaurus-theme/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ const optionsSchema = Joi.object({
// linki zewnętrzne w odpowiedzi modelu: "allow" = klikalne (rel=noreferrer), "text" = sam tekst
// z widocznym hostem (strony rządowe: indirect prompt injection nie da klikalnego phishingu)
externalLinks: Joi.string().valid("allow", "text").default("allow"),
// token strony dla tenanta dokumentacji NIEPUBLICZNEJ: wysyłany w nagłówku X-Site-Token z czatem
// i feedbackiem (API bez niego odpowiada 401). Wypiekany w build (env w workflow), więc widoczny
// w JS strony — chroni treść przed skryptami spoza strony, nie jest sekretem użytkownika.
// Wartość nagłówka: drukowalne ASCII bez spacji (CR/LF = wstrzyknięcie nagłówka)
siteToken: Joi.string()
.pattern(/^[\x21-\x7e]{16,512}$/)
.allow(null)
.default(null),
});

/** klucze sessionStorage per tenant (dwa buildy na jednym originie nie kolidują) */
Expand All @@ -76,6 +84,7 @@ module.exports = function themeDocsChat(context, options) {
selectionActions: options.selectionActions,
statusProbe: options.statusProbe,
externalLinks: options.externalLinks,
siteToken: options.siteToken,
storageKeys: keys,
};

Expand Down
Loading
Loading