From 3b59674d5860e714aa0511af9979eac4b6d09e4a Mon Sep 17 00:00:00 2001 From: Damikk19 Date: Tue, 8 Sep 2026 14:48:18 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20token=20strony=20=E2=80=94=20opcja=20si?= =?UTF-8?q?teToken,=20nag=C5=82=C3=B3wek=20X-Site-Token,=20komunikat=20prz?= =?UTF-8?q?y=20401?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/site-token.md | 6 +++ fixtures/site/docusaurus.config.js | 2 + packages/client/README.md | 19 +++++++ .../schemas/status-response.schema.json | 6 +++ packages/client/src/conversation.mjs | 10 ++-- packages/client/src/index.mjs | 2 +- packages/client/src/transport.mjs | 30 +++++++++-- packages/client/tests/site-token.test.mjs | 52 +++++++++++++++++++ packages/docusaurus-theme/README.md | 1 + packages/docusaurus-theme/locales/pl.json | 1 + .../src/DocsChat/StreamingAnswer/index.js | 1 + .../docusaurus-theme/src/DocsChat/i18n.js | 5 ++ .../docusaurus-theme/src/DocsChat/index.js | 2 +- .../src/DocsChat/useDocsChat.js | 23 +++++--- packages/docusaurus-theme/src/index.js | 9 ++++ .../docusaurus-theme/tests/lifecycle.test.mjs | 13 +++++ 16 files changed, 165 insertions(+), 17 deletions(-) create mode 100644 .changeset/site-token.md create mode 100644 packages/client/tests/site-token.test.mjs diff --git a/.changeset/site-token.md b/.changeset/site-token.md new file mode 100644 index 0000000..46dabe6 --- /dev/null +++ b/.changeset/site-token.md @@ -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`. diff --git a/fixtures/site/docusaurus.config.js b/fixtures/site/docusaurus.config.js index 3b75522..d55a4f0 100644 --- a/fixtures/site/docusaurus.config.js +++ b/fixtures/site/docusaurus.config.js @@ -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, }, ], ], diff --git a/packages/client/README.md b/packages/client/README.md index 2dc8d4a..944c9a5 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -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 diff --git a/packages/client/schemas/status-response.schema.json b/packages/client/schemas/status-response.schema.json index b7538a3..e63397c 100644 --- a/packages/client/schemas/status-response.schema.json +++ b/packages/client/schemas/status-response.schema.json @@ -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": { diff --git a/packages/client/src/conversation.mjs b/packages/client/src/conversation.mjs index 88f9b9f..8dce7c3 100644 --- a/packages/client/src/conversation.mjs +++ b/packages/client/src/conversation.mjs @@ -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) @@ -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 }} ConversationStep */ /** * @typedef {{ @@ -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"}` diff --git a/packages/client/src/index.mjs b/packages/client/src/index.mjs index cadd908..700f2c4 100644 --- a/packages/client/src/index.mjs +++ b/packages/client/src/index.mjs @@ -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, diff --git a/packages/client/src/transport.mjs b/packages/client/src/transport.mjs index cbeac3a..344b9cd 100644 --- a/packages/client/src/transport.mjs +++ b/packages/client/src/transport.mjs @@ -4,6 +4,26 @@ // „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, @@ -11,22 +31,24 @@ import { createSseParser } from "./sse.mjs"; * signal: AbortSignal, * onEvent: (event: any) => void, // ramki + `error`/`done` z `at` * onActivity?: () => void, // każdy chunk odpowiedzi (zerowanie timeoutu) + * headers?: Record, // nagłówki własne (np. { [SITE_TOKEN_HEADER]: token }); Content-Type zawsze JSON * fetchImpl?: typeof fetch, * now?: () => number, * }} options - * @returns {Promise} rozwiązuje po `done`/`error`; rzuca przy błędzie HTTP/sieci (nie przy abort) + * @returns {Promise} 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(); diff --git a/packages/client/tests/site-token.test.mjs b/packages/client/tests/site-token.test.mjs new file mode 100644 index 0000000..3c42209 --- /dev/null +++ b/packages/client/tests/site-token.test.mjs @@ -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"); +}); diff --git a/packages/docusaurus-theme/README.md b/packages/docusaurus-theme/README.md index 08bb8c8..327cb82 100644 --- a/packages/docusaurus-theme/README.md +++ b/packages/docusaurus-theme/README.md @@ -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 diff --git a/packages/docusaurus-theme/locales/pl.json b/packages/docusaurus-theme/locales/pl.json index 97c5c33..68bf52e 100644 --- a/packages/docusaurus-theme/locales/pl.json +++ b/packages/docusaurus-theme/locales/pl.json @@ -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ń", diff --git a/packages/docusaurus-theme/src/DocsChat/StreamingAnswer/index.js b/packages/docusaurus-theme/src/DocsChat/StreamingAnswer/index.js index 0ac981b..696b4f6 100644 --- a/packages/docusaurus-theme/src/DocsChat/StreamingAnswer/index.js +++ b/packages/docusaurus-theme/src/DocsChat/StreamingAnswer/index.js @@ -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 diff --git a/packages/docusaurus-theme/src/DocsChat/i18n.js b/packages/docusaurus-theme/src/DocsChat/i18n.js index 8090179..7a1b9db 100644 --- a/packages/docusaurus-theme/src/DocsChat/i18n.js +++ b/packages/docusaurus-theme/src/DocsChat/i18n.js @@ -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" }), diff --git a/packages/docusaurus-theme/src/DocsChat/index.js b/packages/docusaurus-theme/src/DocsChat/index.js index be1e36c..eb0856f 100644 --- a/packages/docusaurus-theme/src/DocsChat/index.js +++ b/packages/docusaurus-theme/src/DocsChat/index.js @@ -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, diff --git a/packages/docusaurus-theme/src/DocsChat/useDocsChat.js b/packages/docusaurus-theme/src/DocsChat/useDocsChat.js index b866ef2..f16a341 100644 --- a/packages/docusaurus-theme/src/DocsChat/useDocsChat.js +++ b/packages/docusaurus-theme/src/DocsChat/useDocsChat.js @@ -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"; @@ -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 @@ -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) { @@ -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) { @@ -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`. @@ -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(() => ({})) : {}; @@ -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(() => { diff --git a/packages/docusaurus-theme/src/index.js b/packages/docusaurus-theme/src/index.js index eb659c2..6fc5b7b 100644 --- a/packages/docusaurus-theme/src/index.js +++ b/packages/docusaurus-theme/src/index.js @@ -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ą) */ @@ -76,6 +84,7 @@ module.exports = function themeDocsChat(context, options) { selectionActions: options.selectionActions, statusProbe: options.statusProbe, externalLinks: options.externalLinks, + siteToken: options.siteToken, storageKeys: keys, }; diff --git a/packages/docusaurus-theme/tests/lifecycle.test.mjs b/packages/docusaurus-theme/tests/lifecycle.test.mjs index d50c3f6..5fc4e97 100644 --- a/packages/docusaurus-theme/tests/lifecycle.test.mjs +++ b/packages/docusaurus-theme/tests/lifecycle.test.mjs @@ -70,3 +70,16 @@ test("tłumaczenia: pl i fallback pl-PL → pl.json, nieznany locale → {}", () assert.deepEqual(makeTheme({}, "pl-PL").getDefaultCodeTranslationMessages(), pl); assert.deepEqual(makeTheme({}, "de").getDefaultCodeTranslationMessages(), {}); }); + +test("siteToken: opcjonalny; drukowalne ASCII 16–512 bez spacji/CRLF; trafia do global data, nie do inline