+
diff --git a/frontend/src/plate/types.ts b/frontend/src/plate/types.ts
index cde46207c..c56eb457d 100644
--- a/frontend/src/plate/types.ts
+++ b/frontend/src/plate/types.ts
@@ -1,5 +1,6 @@
/* eslint-disable max-lines */
/* eslint-disable import/no-unused-modules */
+import { CursorEditor, YjsEditor } from '@slate-yjs/core';
import { AutoformatRule } from '@udecode/plate-autoformat';
import {
PlateEditor,
@@ -22,6 +23,7 @@ import {
TTableElement,
TTableRowElement,
} from '@udecode/plate-table';
+import { CursorEditorProps, PlateYjsEditorProps } from '@udecode/plate-yjs';
import {
ELEMENT_CURRENT_DATE,
ELEMENT_EMPTY_VOID,
@@ -37,6 +39,7 @@ import {
ELEMENT_REGELVERK_CONTAINER,
ELEMENT_SIGNATURE,
} from '@app/plate/plugins/element-types';
+import { IGetConsumerMaltekstseksjonerParams } from '@app/types/common-text-types';
import { Language } from '@app/types/texts/language';
import { TemplateSections } from './template-sections';
@@ -148,6 +151,8 @@ export interface MaltekstseksjonElement extends BlockElement {
section: TemplateSections;
textIdList: string[];
children: (MaltekstElement | RedigerbarMaltekstElement)[] | [EmptyVoidElement];
+ query?: IGetConsumerMaltekstseksjonerParams;
+ language?: Language;
}
export interface PlaceholderElement extends BlockElement {
@@ -174,6 +179,12 @@ export interface EmptyVoidElement extends BlockElement {
export interface RegelverkContainerElement extends BlockElement {
type: typeof ELEMENT_REGELVERK_CONTAINER;
children: ParentOrChildElement[];
+ query?: RegelverkQuery;
+}
+
+export interface RegelverkQuery {
+ ytelseHjemmelIdList?: string[];
+ utfallIdList?: string;
}
export interface RegelverkElement extends BlockElement {
@@ -275,5 +286,7 @@ export type EditorPlatePlugin = PlatePlugin
;
export type EditorAutoformatRule = AutoformatRule;
-export const useMyPlateEditorRef = (id?: PlateId) => useEditorRef(id);
-export const useMyPlateEditorState = (id?: PlateId) => useEditorState(id);
+export const useMyPlateEditorRef = (id?: PlateId) =>
+ useEditorRef(id);
+export const useMyPlateEditorState = (id?: PlateId) =>
+ useEditorState(id);
diff --git a/frontend/src/redux-api/collaboration.ts b/frontend/src/redux-api/collaboration.ts
new file mode 100644
index 000000000..5bae49aaf
--- /dev/null
+++ b/frontend/src/redux-api/collaboration.ts
@@ -0,0 +1,47 @@
+import { createApi } from '@reduxjs/toolkit/query/react';
+import { toast } from '@app/components/toast/store';
+import { apiErrorToast } from '@app/components/toast/toast-content/fetch-error-toast';
+import { PROXY_BASE_QUERY } from '@app/redux-api/common';
+import { documentsQuerySlice } from '@app/redux-api/oppgaver/queries/documents';
+import { ISmartDocument } from '@app/types/documents/documents';
+import { isApiRejectionError } from '@app/types/errors';
+import { ICreateSmartDocumentParams } from '@app/types/smart-editor/params';
+
+export const collaborationApi = createApi({
+ reducerPath: 'collaborationApi',
+ baseQuery: PROXY_BASE_QUERY,
+ endpoints: (builder) => ({
+ createSmartDocument: builder.mutation({
+ query: ({ oppgaveId, ...body }) => ({
+ url: `/collaboration/behandlinger/${oppgaveId}/dokumenter`,
+ method: 'POST',
+ body,
+ }),
+ onQueryStarted: async ({ oppgaveId }, { dispatch, queryFulfilled }) => {
+ try {
+ const { data } = await queryFulfilled;
+
+ dispatch(
+ documentsQuerySlice.util.updateQueryData('getDocuments', oppgaveId, (draft) =>
+ draft.some((e) => e.id === data.id) ? draft : [data, ...draft],
+ ),
+ );
+
+ dispatch(
+ documentsQuerySlice.util.updateQueryData('getDocument', { dokumentId: data.id, oppgaveId }, () => data),
+ );
+ } catch (e) {
+ const message = 'Kunne ikke opprette dokument.';
+
+ if (isApiRejectionError(e)) {
+ apiErrorToast(message, e.error);
+ } else {
+ toast.error(message);
+ }
+ }
+ },
+ }),
+ }),
+});
+
+export const { useCreateSmartDocumentMutation } = collaborationApi;
diff --git a/frontend/src/redux-api/common.ts b/frontend/src/redux-api/common.ts
index faca136b9..e9d0d4958 100644
--- a/frontend/src/redux-api/common.ts
+++ b/frontend/src/redux-api/common.ts
@@ -6,7 +6,7 @@ export const IS_LOCALHOST = window.location.hostname === 'localhost';
const mode: RequestMode | undefined = IS_LOCALHOST ? 'cors' : undefined;
-const staggeredBaseQuery = (baseUrl: string) => {
+export const staggeredBaseQuery = (baseUrl: string) => {
const fetch = fetchBaseQuery({
baseUrl,
mode,
@@ -50,6 +50,8 @@ const staggeredBaseQuery = (baseUrl: string) => {
);
};
+export const PROXY_BASE_QUERY = staggeredBaseQuery('');
+
const API_PATH = '/api';
export const API_BASE_QUERY = staggeredBaseQuery(API_PATH);
diff --git a/frontend/src/redux-api/maltekstseksjoner/consumer.ts b/frontend/src/redux-api/maltekstseksjoner/consumer.ts
index 423b0db0a..515e0af32 100644
--- a/frontend/src/redux-api/maltekstseksjoner/consumer.ts
+++ b/frontend/src/redux-api/maltekstseksjoner/consumer.ts
@@ -1,8 +1,10 @@
import { createApi } from '@reduxjs/toolkit/query/react';
-import { IGetTextsParams } from '@app/types/common-text-types';
+import {
+ IGetConsumerMaltekstseksjonTextsParams,
+ IGetConsumerMaltekstseksjonerParams,
+} from '@app/types/common-text-types';
import { IMaltekstseksjon } from '@app/types/maltekstseksjoner/responses';
import { IConsumerRichText } from '@app/types/texts/consumer';
-import { Language } from '@app/types/texts/language';
import { KABAL_TEXT_TEMPLATES_BASE_QUERY } from '../common';
export enum ConsumerMaltekstseksjonerTagTypes {
@@ -16,12 +18,12 @@ export const consumerMaltekstseksjonerApi = createApi({
baseQuery: KABAL_TEXT_TEMPLATES_BASE_QUERY,
tagTypes: Object.values(ConsumerMaltekstseksjonerTagTypes),
endpoints: (builder) => ({
- getConsumerMaltekstseksjoner: builder.query({
+ getConsumerMaltekstseksjoner: builder.query({
query: (params) => ({ url: '/consumer/maltekstseksjoner', params }),
providesTags: (maltekstseksjoner) =>
maltekstseksjoner?.map(({ id }) => ({ type: ConsumerMaltekstseksjonerTagTypes.MALTEKSTSEKSJON, id })) ?? [],
}),
- getMaltekstseksjonTexts: builder.query({
+ getMaltekstseksjonTexts: builder.query({
query: ({ id, language }) => `/consumer/maltekstseksjoner/${id}/texts/${language}`,
providesTags: (_, __, { id, language }) => [
{ type: ConsumerMaltekstseksjonerTagTypes.MALTEKSTSEKSJON_TEXTS, id, language },
@@ -31,7 +33,7 @@ export const consumerMaltekstseksjonerApi = createApi({
});
export const {
+ useGetMaltekstseksjonTextsQuery,
useLazyGetMaltekstseksjonTextsQuery,
useLazyGetConsumerMaltekstseksjonerQuery,
- useGetMaltekstseksjonTextsQuery,
} = consumerMaltekstseksjonerApi;
diff --git a/frontend/src/redux-api/oppgaver/mutations/set-utfall.ts b/frontend/src/redux-api/oppgaver/mutations/set-utfall.ts
index cc905f9db..1d6a06da6 100644
--- a/frontend/src/redux-api/oppgaver/mutations/set-utfall.ts
+++ b/frontend/src/redux-api/oppgaver/mutations/set-utfall.ts
@@ -24,6 +24,10 @@ const setUtfallMutationSlice = oppgaverApi.injectEndpoints({
const oppgavebehandlingPatchResult = dispatch(
behandlingerQuerySlice.util.updateQueryData('getOppgavebehandling', oppgaveId, (draft) => {
draft.resultat.utfallId = utfallId;
+
+ if (utfallId === null) {
+ draft.resultat.extraUtfallIdSet = [];
+ }
}),
);
@@ -40,6 +44,7 @@ const setUtfallMutationSlice = oppgaverApi.injectEndpoints({
behandlingerQuerySlice.util.updateQueryData('getOppgavebehandling', oppgaveId, (draft) => {
draft.modified = data.modified;
draft.resultat.utfallId = data.utfallId;
+ draft.resultat.extraUtfallIdSet = data.extraUtfallIdSet;
}),
);
diff --git a/frontend/src/redux-api/oppgaver/mutations/smart-document.ts b/frontend/src/redux-api/oppgaver/mutations/smart-document.ts
index 9f7ee67f5..0445022cc 100644
--- a/frontend/src/redux-api/oppgaver/mutations/smart-document.ts
+++ b/frontend/src/redux-api/oppgaver/mutations/smart-document.ts
@@ -1,11 +1,5 @@
import { toast } from '@app/components/toast/store';
-import { apiErrorToast } from '@app/components/toast/toast-content/fetch-error-toast';
-import { user } from '@app/static-data/static-data';
import { IDocumentParams } from '@app/types/documents/common-params';
-import { ISmartDocument } from '@app/types/documents/documents';
-import { IModifiedSmartDocumentResponse } from '@app/types/documents/response';
-import { isApiRejectionError } from '@app/types/errors';
-import { ICreateSmartDocumentParams, IUpdateSmartDocumentParams } from '@app/types/smart-editor/params';
import { Language } from '@app/types/texts/language';
import { IS_LOCALHOST } from '../../common';
import { oppgaverApi } from '../oppgaver';
@@ -14,94 +8,6 @@ import { documentsQuerySlice } from '../queries/documents';
const smartDocumentsMutationSlice = oppgaverApi.injectEndpoints({
overrideExisting: IS_LOCALHOST,
endpoints: (builder) => ({
- createSmartDocument: builder.mutation({
- query: ({ oppgaveId, richText: content, dokumentTypeId, templateId, tittel, parentId }) => ({
- url: `/kabal-api/behandlinger/${oppgaveId}/smartdokumenter`,
- method: 'POST',
- body: { content, dokumentTypeId, templateId, tittel, parentId },
- }),
- onQueryStarted: async ({ oppgaveId }, { dispatch, queryFulfilled }) => {
- try {
- const { data } = await queryFulfilled;
-
- dispatch(
- documentsQuerySlice.util.updateQueryData('getDocuments', oppgaveId, (draft) =>
- draft.some((e) => e.id === data.id) ? draft : [data, ...draft],
- ),
- );
-
- dispatch(
- documentsQuerySlice.util.updateQueryData('getDocument', { dokumentId: data.id, oppgaveId }, () => data),
- );
- } catch (e) {
- const message = 'Kunne ikke opprette dokument.';
-
- if (isApiRejectionError(e)) {
- apiErrorToast(message, e.error);
- } else {
- toast.error(message);
- }
- }
- },
- }),
-
- updateSmartDocument: builder.mutation({
- query: ({ oppgaveId, dokumentId, ...body }) => ({
- url: `/kabal-api/behandlinger/${oppgaveId}/smartdokumenter/${dokumentId}`,
- method: 'PATCH',
- body,
- timeout: 10_000,
- }),
- onQueryStarted: async ({ dokumentId, oppgaveId, content }, { dispatch, queryFulfilled }) => {
- try {
- const { navIdent, navn } = await user;
- const { data } = await queryFulfilled;
- const { modified, version } = data;
-
- dispatch(
- documentsQuerySlice.util.updateQueryData('getDocument', { dokumentId, oppgaveId }, (draft) => {
- if (draft !== null && draft.isSmartDokument) {
- return { ...draft, content, modified, version };
- }
- }),
- );
-
- dispatch(
- documentsQuerySlice.util.updateQueryData('getDocuments', oppgaveId, (draft) =>
- draft.map((d) => (d.isSmartDokument && d.id === dokumentId ? { ...d, modified, version, content } : d)),
- ),
- );
-
- dispatch(
- documentsQuerySlice.util.updateQueryData(
- 'getSmartDocumentVersion',
- { dokumentId, oppgaveId, versionId: version },
- () => content,
- ),
- );
-
- dispatch(
- documentsQuerySlice.util.updateQueryData('getSmartDocumentVersions', { dokumentId, oppgaveId }, (draft) => [
- {
- version,
- timestamp: modified,
- author: { navIdent, navn },
- },
- ...draft,
- ]),
- );
- } catch (e: unknown) {
- const message = 'Feil ved lagring av dokument.';
-
- if (isApiRejectionError(e)) {
- apiErrorToast(message, e.error);
- } else {
- toast.error(message);
- }
- }
- },
- }),
-
setLanguage: builder.mutation({
query: ({ dokumentId, oppgaveId, language }) => ({
url: `/kabal-api/behandlinger/${oppgaveId}/dokumenter/${dokumentId}/language`,
@@ -135,5 +41,4 @@ const smartDocumentsMutationSlice = oppgaverApi.injectEndpoints({
}),
});
-export const { useCreateSmartDocumentMutation, useUpdateSmartDocumentMutation, useSetLanguageMutation } =
- smartDocumentsMutationSlice;
+export const { useSetLanguageMutation } = smartDocumentsMutationSlice;
diff --git a/frontend/src/redux-api/oppgaver/queries/behandling/event-handlers/smart-document/language-changed.tsx b/frontend/src/redux-api/oppgaver/queries/behandling/event-handlers/smart-document/language-changed.tsx
index fc83132b7..fca19ca51 100644
--- a/frontend/src/redux-api/oppgaver/queries/behandling/event-handlers/smart-document/language-changed.tsx
+++ b/frontend/src/redux-api/oppgaver/queries/behandling/event-handlers/smart-document/language-changed.tsx
@@ -9,10 +9,22 @@ import { LANGUAGE_NAMES } from '@app/types/texts/language';
export const handleSmartDocumentLanguageChangedEvent =
(oppgaveId: string, userId: string) => (event: SmartDocumentLanguageEvent) => {
+ reduxStore.dispatch(
+ documentsQuerySlice.util.updateQueryData('getDocument', { oppgaveId, dokumentId: event.document.id }, (draft) => {
+ if (!draft.isSmartDokument) {
+ return draft;
+ }
+
+ draft.language = event.document.language;
+
+ return draft;
+ }),
+ );
+
reduxStore.dispatch(
documentsQuerySlice.util.updateQueryData('getDocuments', oppgaveId, (draft) =>
draft.map((document) => {
- if (!document.isSmartDokument || document.id !== event.id) {
+ if (!document.isSmartDokument || document.id !== event.document.id) {
return document;
}
@@ -25,7 +37,7 @@ export const handleSmartDocumentLanguageChangedEvent =
const to = (
- {LANGUAGE_NAMES[event.language]}
+ {LANGUAGE_NAMES[event.document.language]}
);
@@ -36,7 +48,7 @@ export const handleSmartDocumentLanguageChangedEvent =
);
}
- return { ...document, language: event.language };
+ return { ...document, language: event.document.language };
}),
),
);
diff --git a/frontend/src/redux-api/oppgaver/queries/behandling/event-handlers/smart-document/versioned.tsx b/frontend/src/redux-api/oppgaver/queries/behandling/event-handlers/smart-document/versioned.tsx
index 45768eef2..f3ad518c6 100644
--- a/frontend/src/redux-api/oppgaver/queries/behandling/event-handlers/smart-document/versioned.tsx
+++ b/frontend/src/redux-api/oppgaver/queries/behandling/event-handlers/smart-document/versioned.tsx
@@ -9,9 +9,13 @@ export const handleSmartDocumentVersionedEvent =
documentsQuerySlice.util.updateQueryData(
'getSmartDocumentVersions',
{ oppgaveId, dokumentId: documentId },
- (draft) => {
- draft.push({ author, timestamp, version });
- },
+ (draft) => [{ author, timestamp, version }, ...draft],
),
);
+
+ reduxStore.dispatch(
+ documentsQuerySlice.util.updateQueryData('getDocument', { oppgaveId, dokumentId: documentId }, (draft) => {
+ draft.modified = timestamp;
+ }),
+ );
};
diff --git a/frontend/src/redux-api/oppgaver/queries/behandling/event-handlers/utfall.tsx b/frontend/src/redux-api/oppgaver/queries/behandling/event-handlers/utfall.tsx
index 4477cf627..fbbbbc8da 100644
--- a/frontend/src/redux-api/oppgaver/queries/behandling/event-handlers/utfall.tsx
+++ b/frontend/src/redux-api/oppgaver/queries/behandling/event-handlers/utfall.tsx
@@ -34,6 +34,10 @@ export const handleUtfallEvent =
);
}
+ if (utfallId === null) {
+ draft.resultat.extraUtfallIdSet = [];
+ }
+
draft.resultat.utfallId = utfallId;
draft.modified = timestamp;
});
diff --git a/frontend/src/redux-api/oppgaver/queries/oppgaver.ts b/frontend/src/redux-api/oppgaver/queries/oppgaver.ts
index dd5fd4819..6da372434 100644
--- a/frontend/src/redux-api/oppgaver/queries/oppgaver.ts
+++ b/frontend/src/redux-api/oppgaver/queries/oppgaver.ts
@@ -69,6 +69,9 @@ const oppgaverQuerySlice = oppgaverApi.injectEndpoints({
body: { identifikator },
}),
}),
+ searchOppgaverBySaksnummer: builder.query({
+ query: (saksnummer) => ({ url: `/kabal-api/search/saksnummer`, params: { saksnummer } }),
+ }),
getSaksbehandlereInEnhet: builder.query({
query: (enhet) => `/kabal-search/enheter/${enhet}/saksbehandlere`,
}),
@@ -114,7 +117,7 @@ export const {
useGetLedigeOppgaverQuery,
useGetAntallLedigeOppgaverMedUtgaatteFristerQuery,
useSearchOppgaverByFnrQuery,
- useLazySearchPeopleByNameQuery,
+ useLazySearchOppgaverBySaksnummerQuery,
useGetSaksbehandlereInEnhetQuery,
useSearchPersonByFnrQuery,
useGetReturnerteRolOppgaverQuery,
diff --git a/frontend/src/redux-api/server-sent-events/types.ts b/frontend/src/redux-api/server-sent-events/types.ts
index 064dec57a..6771a97e8 100644
--- a/frontend/src/redux-api/server-sent-events/types.ts
+++ b/frontend/src/redux-api/server-sent-events/types.ts
@@ -143,8 +143,7 @@ export interface SmartDocumentVersionedEvent extends BaseEvent {
}
export interface SmartDocumentLanguageEvent extends BaseEvent {
- id: string;
- language: Language;
+ document: { id: string; language: Language };
}
export interface SmartDocumentCommentEvent extends BaseEvent {
diff --git a/frontend/src/redux-api/texts/consumer.ts b/frontend/src/redux-api/texts/consumer.ts
index 9352b2a4e..c5385b074 100644
--- a/frontend/src/redux-api/texts/consumer.ts
+++ b/frontend/src/redux-api/texts/consumer.ts
@@ -1,5 +1,11 @@
import { createApi } from '@reduxjs/toolkit/query/react';
-import { IGetConsumerTextParams, IGetConsumerTextsParams } from '@app/types/common-text-types';
+import {
+ IGetConsumerGodFormuleringParams,
+ IGetConsumerHeaderFooterParams,
+ IGetConsumerRegelverkParams,
+ IGetConsumerTextParams,
+ IGetConsumerTextsParams,
+} from '@app/types/common-text-types';
import { IConsumerText } from '@app/types/texts/consumer';
import { KABAL_TEXT_TEMPLATES_BASE_QUERY } from '../common';
@@ -7,12 +13,18 @@ export enum ConsumerTextsTagTypes {
TEXT = 'consumer-text',
}
+type Params =
+ | IGetConsumerTextsParams
+ | IGetConsumerGodFormuleringParams
+ | IGetConsumerRegelverkParams
+ | IGetConsumerHeaderFooterParams;
+
export const consumerTextsApi = createApi({
reducerPath: 'consumerTextsApi',
baseQuery: KABAL_TEXT_TEMPLATES_BASE_QUERY,
tagTypes: Object.values(ConsumerTextsTagTypes),
endpoints: (builder) => ({
- getConsumerTexts: builder.query({
+ getConsumerTexts: builder.query({
query: ({ language, ...params }) => ({ url: `/consumer/texts/${language}`, params }),
providesTags: (texts, _, { language }) =>
texts === undefined
diff --git a/frontend/src/redux/configure-store.ts b/frontend/src/redux/configure-store.ts
index 647279af3..3a5465403 100644
--- a/frontend/src/redux/configure-store.ts
+++ b/frontend/src/redux/configure-store.ts
@@ -3,6 +3,7 @@ import { configureStore } from '@reduxjs/toolkit';
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import { accessRightsApi } from '@app/redux-api/access-rights';
import { brukerApi } from '@app/redux-api/bruker';
+import { collaborationApi } from '@app/redux-api/collaboration';
import { kabalInternalApi } from '@app/redux-api/internal';
import { journalposterApi } from '@app/redux-api/journalposter';
import { kvalitetsvurderingV1Api } from '@app/redux-api/kaka-kvalitetsvurdering/v1';
@@ -43,6 +44,7 @@ export const reduxStore = configureStore({
searchApi.middleware,
logiskeVedleggApi.middleware,
svarbrevApi.middleware,
+ collaborationApi.middleware,
]),
});
diff --git a/frontend/src/redux/root.ts b/frontend/src/redux/root.ts
index 93a58cf70..e6a0cf14a 100644
--- a/frontend/src/redux/root.ts
+++ b/frontend/src/redux/root.ts
@@ -1,6 +1,7 @@
import { combineReducers } from 'redux';
import { accessRightsApi } from '@app/redux-api/access-rights';
import { brukerApi } from '@app/redux-api/bruker';
+import { collaborationApi } from '@app/redux-api/collaboration';
import { kabalInternalApi } from '@app/redux-api/internal';
import { journalposterApi } from '@app/redux-api/journalposter';
import { kvalitetsvurderingV1Api } from '@app/redux-api/kaka-kvalitetsvurdering/v1';
@@ -33,6 +34,7 @@ export const rootReducer = combineReducers({
[searchApi.reducerPath]: searchApi.reducer,
[logiskeVedleggApi.reducerPath]: logiskeVedleggApi.reducer,
[svarbrevApi.reducerPath]: svarbrevApi.reducer,
+ [collaborationApi.reducerPath]: collaborationApi.reducer,
});
export type RootState = ReturnType;
diff --git a/frontend/src/types/common-text-types.ts b/frontend/src/types/common-text-types.ts
index 6c3b6fabd..50ae9a312 100644
--- a/frontend/src/types/common-text-types.ts
+++ b/frontend/src/types/common-text-types.ts
@@ -75,10 +75,44 @@ export interface IGetTextsParams extends IGetMaltekstseksjonParams {
textType: TextTypes;
}
+/** Deprecated
+ * @deprecated Remove when no longer in use by legacy (redigerbar) maltekst.
+ */
export interface IGetConsumerTextsParams extends Omit {
language: Language | typeof UNTRANSLATED;
}
+export interface IGetConsumerMaltekstseksjonerParams {
+ ytelseHjemmelIdList: string[];
+ templateSectionIdList: string[];
+ utfallIdList: string;
+}
+
+export interface IGetConsumerMaltekstseksjonTextsParams {
+ id: string;
+ language: Language;
+}
+
+export interface IGetConsumerGodFormuleringParams {
+ ytelseHjemmelIdList: string[];
+ templateSectionIdList: string[];
+ utfallIdList: string;
+ textType: typeof GOD_FORMULERING_TYPE;
+ language: Language;
+}
+
+export interface IGetConsumerRegelverkParams {
+ ytelseHjemmelIdList: string[];
+ utfallIdList: string;
+ textType: typeof REGELVERK_TYPE;
+ language: typeof UNTRANSLATED;
+}
+
+export interface IGetConsumerHeaderFooterParams extends Omit {
+ textType: PlainTextTypes;
+ language: Language;
+}
+
export interface IGetConsumerTextParams {
language: Language | typeof UNTRANSLATED;
textId: string;
diff --git a/frontend/src/types/documents/response.ts b/frontend/src/types/documents/response.ts
index 3d9e1a3a9..ab3757037 100644
--- a/frontend/src/types/documents/response.ts
+++ b/frontend/src/types/documents/response.ts
@@ -19,7 +19,3 @@ export interface ISetParentResponse extends IModifiedDocumentResponse {
export interface IModifiedDocumentResponse {
modified: string;
}
-
-export interface IModifiedSmartDocumentResponse extends IModifiedDocumentResponse {
- version: number;
-}
diff --git a/frontend/src/types/field-names.ts b/frontend/src/types/field-names.ts
index 4b079cf5d..971f3cec5 100644
--- a/frontend/src/types/field-names.ts
+++ b/frontend/src/types/field-names.ts
@@ -34,6 +34,7 @@ export const FIELD_NAMES = {
[SaksTypeEnum.KLAGE]: DEFAULT_FIELD_NAMES,
[SaksTypeEnum.ANKE]: { ...DEFAULT_FIELD_NAMES, ...ANKE_FIELD_NAMES },
[SaksTypeEnum.ANKE_I_TRYGDERETTEN]: { ...DEFAULT_FIELD_NAMES, ...ANKE_I_TRYGDERETTEN_FIELD_NAMES },
+ [SaksTypeEnum.BEHANDLING_ETTER_TR_OPPHEVET]: DEFAULT_FIELD_NAMES,
};
export type Field = keyof typeof DEFAULT_FIELD_NAMES;
diff --git a/frontend/src/types/kodeverk.ts b/frontend/src/types/kodeverk.ts
index 35fcbf520..82ff561b7 100644
--- a/frontend/src/types/kodeverk.ts
+++ b/frontend/src/types/kodeverk.ts
@@ -8,6 +8,7 @@ export enum SaksTypeEnum {
KLAGE = '1',
ANKE = '2',
ANKE_I_TRYGDERETTEN = '3',
+ BEHANDLING_ETTER_TR_OPPHEVET = '4',
}
export enum UtfallEnum {
diff --git a/frontend/src/types/oppgave-common.ts b/frontend/src/types/oppgave-common.ts
index e5554ea0b..c145fd5dc 100644
--- a/frontend/src/types/oppgave-common.ts
+++ b/frontend/src/types/oppgave-common.ts
@@ -1,5 +1,6 @@
import { IAddress } from '@app/types/documents/recipients';
import { SexEnum } from '@app/types/kodeverk';
+import { Language } from '@app/types/texts/language';
import { INavEmployee } from './bruker';
export interface IJournalfoertDokumentId {
@@ -111,6 +112,7 @@ export type IPart = IPersonPart | IOrganizationPart;
export interface ISakenGjelder extends IPersonPart {
sex: SexEnum;
+ language: Language;
}
export interface IVenteperiode {
diff --git a/frontend/src/types/oppgavebehandling/oppgavebehandling.ts b/frontend/src/types/oppgavebehandling/oppgavebehandling.ts
index d8d6638da..cf13624c3 100644
--- a/frontend/src/types/oppgavebehandling/oppgavebehandling.ts
+++ b/frontend/src/types/oppgavebehandling/oppgavebehandling.ts
@@ -83,7 +83,17 @@ export interface ITrygderettsankebehandling extends IOppgavebehandlingBase {
sendtTilTrygderetten: string | null; // LocalDate
}
-export type IOppgavebehandling = IKlagebehandling | IAnkebehandling | ITrygderettsankebehandling;
+export interface IBehandlingEtterTryderettenOpphevet extends IOppgavebehandlingBase {
+ typeId: SaksTypeEnum.BEHANDLING_ETTER_TR_OPPHEVET;
+ rol: IMedunderskriverRol;
+ kjennelseMottatt: string | null;
+}
+
+export type IOppgavebehandling =
+ | IKlagebehandling
+ | IAnkebehandling
+ | ITrygderettsankebehandling
+ | IBehandlingEtterTryderettenOpphevet;
interface Resultat {
file: IVedlegg | null;
diff --git a/frontend/src/types/oppgaver.ts b/frontend/src/types/oppgaver.ts
index 904749c77..5f98030f8 100644
--- a/frontend/src/types/oppgaver.ts
+++ b/frontend/src/types/oppgaver.ts
@@ -26,9 +26,18 @@ interface IOppgaveRowVenteperiode extends IVenteperiode {
export interface IOppgave {
/** Age in days. */
ageKA: number;
+ /** Date
+ * @format yyyy-MM-dd
+ */
avsluttetAvSaksbehandlerDate: DateString | null;
fagsystemId: string;
+ /** Date
+ * @format yyyy-MM-dd
+ */
frist: DateString | null;
+ /** Date
+ * @format yyyy-MM-dd
+ */
varsletFrist: DateString | null;
hjemmelIdList: string[];
registreringshjemmelIdList: string[];
@@ -36,6 +45,9 @@ export interface IOppgave {
isAvsluttetAvSaksbehandler: boolean;
medunderskriver: IHelper;
rol: IHelper;
+ /** Date
+ * @format yyyy-MM-dd
+ */
mottatt: DateString;
tildeltSaksbehandlerident: string | null;
/** DateTime */
@@ -46,6 +58,9 @@ export interface IOppgave {
sattPaaVent: IOppgaveRowVenteperiode | null;
feilregistrert: DateString | null;
saksnummer: string;
+ /** Date
+ * @format yyyy-MM-dd
+ */
datoSendtTilTR: DateString | null;
previousSaksbehandler: INavEmployee | null;
}
diff --git a/frontend/src/types/smart-editor/params.ts b/frontend/src/types/smart-editor/params.ts
index 4d53c88c6..dfe35cd61 100644
--- a/frontend/src/types/smart-editor/params.ts
+++ b/frontend/src/types/smart-editor/params.ts
@@ -1,8 +1,6 @@
import { TDescendant } from '@udecode/plate-common';
-import { EditorValue } from '@app/plate/types';
import { Language } from '@app/types/texts/language';
import { Role } from '../bruker';
-import { IDocumentParams } from '../documents/common-params';
import { DistribusjonsType } from '../documents/documents';
import { IOppgavebehandlingBaseParams } from '../oppgavebehandling/params';
import { Immutable } from '../types';
@@ -10,8 +8,8 @@ import { TemplateIdEnum } from './template-enums';
interface IMutableCreateSmartDocumentParams extends IOppgavebehandlingBaseParams {
tittel: string;
- richText: TDescendant[];
- templateId: TemplateIdEnum | null;
+ content: TDescendant[];
+ templateId: TemplateIdEnum;
dokumentTypeId: DistribusjonsType;
parentId: string | null;
creatorIdent: string;
@@ -20,8 +18,3 @@ interface IMutableCreateSmartDocumentParams extends IOppgavebehandlingBaseParams
}
export type ICreateSmartDocumentParams = Immutable;
-
-export interface IUpdateSmartDocumentParams extends IDocumentParams {
- content: EditorValue;
- version: number;
-}
diff --git a/frontend/src/types/smart-editor/template-enums.ts b/frontend/src/types/smart-editor/template-enums.ts
index c23e5367e..012663de9 100644
--- a/frontend/src/types/smart-editor/template-enums.ts
+++ b/frontend/src/types/smart-editor/template-enums.ts
@@ -9,4 +9,5 @@ export enum TemplateIdEnum {
ROL_TILSVARSBREV = 'rol-tilsvarsbrev',
OVERSENDELSESBREV = 'oversendelsesbrev',
ORIENTERING_OM_TILSVAR = 'orientering-om-tilsvar',
+ BEHANDLING_ETTER_TR_OPPHEVET_VEDTAK = 'behandling-etter-tr-opphevet',
}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index b574b3b58..f805276f6 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -2,6 +2,11 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
+const PROXY = {
+ target: 'https://kabal.intern.dev.nav.no',
+ changeOrigin: true,
+};
+
// https://vitejs.dev/config/
export default defineConfig({
plugins: [tsconfigPaths(), react()],
@@ -11,13 +16,13 @@ export default defineConfig({
server: {
port: 8061,
proxy: {
- '/api': 'https://kabal.intern.dev.nav.no',
- '/arkivert-dokument': 'https://kabal.intern.dev.nav.no',
- '/kombinert-dokument': 'https://kabal.intern.dev.nav.no',
- '/nytt-dokument': 'https://kabal.intern.dev.nav.no',
- '/vedleggsoversikt': 'https://kabal.intern.dev.nav.no',
- '/version': 'https://kabal.intern.dev.nav.no',
+ '/api': PROXY,
+ '/collaboration': { ...PROXY, ws: true },
+ '/arkivert-dokument': PROXY,
+ '/kombinert-dokument': PROXY,
+ '/nytt-dokument': PROXY,
+ '/vedleggsoversikt': PROXY,
+ '/version': PROXY,
},
},
-})
-
+});
diff --git a/nais/nais.yaml b/nais/nais.yaml
index c90416284..591088b54 100644
--- a/nais/nais.yaml
+++ b/nais/nais.yaml
@@ -27,7 +27,7 @@ spec:
sidecar:
enabled: true
autoLogin: true
- autoLoginIgnorePaths:
+ autoLoginIgnorePaths:
- /assets/*
- /*.js.map
- /*.js
@@ -65,6 +65,8 @@ spec:
redis:
- instance: obo-cache
access: readwrite
+ - instance: hocuspocus
+ access: readwrite
liveness:
path: /isAlive
initialDelay: 3
diff --git a/server/.vscode/launch.json b/server/.vscode/launch.json
new file mode 100644
index 000000000..757b661a5
--- /dev/null
+++ b/server/.vscode/launch.json
@@ -0,0 +1,18 @@
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "type": "node",
+ "request": "launch",
+ "name": "Launch Program",
+ "skipFiles": [
+ "/**"
+ ],
+ "program": "${workspaceFolder}/dist/server.js",
+ "cwd": "${workspaceFolder}",
+ }
+ ]
+}
\ No newline at end of file
diff --git a/server/bun.lockb b/server/bun.lockb
index aca33b27a..372ed06ff 100755
Binary files a/server/bun.lockb and b/server/bun.lockb differ
diff --git a/server/package.json b/server/package.json
index 5b7fda148..dc795f41f 100644
--- a/server/package.json
+++ b/server/package.json
@@ -9,30 +9,34 @@
"type": "module",
"scripts": {
"start": "bun run build --watch & node --watch --trace-warnings dist/server.js",
- "build": "bun build ./src/server.ts --target node --format esm --sourcemap --outdir dist",
+ "build": "bun build ./src/server.ts --target node --format esm --sourcemap=external --outdir dist",
"lint": "eslint ./src/**/*.ts --color --cache --cache-strategy content --cache-location .eslintcache",
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "@fastify/cors": "9.0.1",
- "@fastify/http-proxy": "9.5.0",
- "@fastify/type-provider-typebox": "4.0.0",
- "@types/node": "20.14.12",
- "fastify": "4.28.1",
- "fastify-metrics": "11.0.0",
- "jose": "5.6.3",
- "openid-client": "5.6.5",
+ "@fastify/cors": "10.0.1",
+ "@fastify/http-proxy": "10.0.0",
+ "@fastify/type-provider-typebox": "5.0.0",
+ "@hocuspocus/common": "2.13.6",
+ "@hocuspocus/server": "2.13.6",
+ "@slate-yjs/core": "1.0.2",
+ "@types/node": "22.5.5",
+ "fastify": "5.0.0",
+ "fastify-metrics": "12.1.0",
+ "jose": "4.15.9",
+ "openid-client": "5.7.0",
"prom-client": "15.1.3",
- "redis": "^4.6.15"
+ "redis": "4.7.0"
},
"devDependencies": {
- "@eslint/js": "9.7.0",
- "@types/bun": "1.1.6",
+ "@eslint/js": "9.11.0",
+ "@types/bun": "1.1.10",
+ "@types/uuid": "10.0.0",
"eslint": "8.57.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-prettier": "5.2.1",
- "globals": "15.8.0",
- "typescript": "5.5.4",
- "typescript-eslint": "7.17.0"
+ "globals": "15.9.0",
+ "typescript": "5.6.2",
+ "typescript-eslint": "8.6.0"
}
-}
+}
\ No newline at end of file
diff --git a/server/src/auth/cache/cache.ts b/server/src/auth/cache/cache.ts
index d8e77d574..60da52caf 100644
--- a/server/src/auth/cache/cache.ts
+++ b/server/src/auth/cache/cache.ts
@@ -47,6 +47,16 @@ class OboTieredCache {
return null;
}
+ public getCached(key: string): string | null {
+ if (this.#oboMemoryCache === null) {
+ return null;
+ }
+
+ const memoryHit = this.#oboMemoryCache.get(key);
+
+ return memoryHit?.token ?? null;
+ }
+
public async set(key: string, token: string, expiresAt: number): Promise {
this.#oboMemoryCache?.set(key, token, expiresAt);
await this.#oboRedisCache.set(key, token, expiresAt);
@@ -66,6 +76,8 @@ class OboSimpleCache {
return memoryHit?.token ?? null;
}
+ public getCached = this.get;
+
public set(key: string, token: string, expiresAt: number): void {
this.#oboMemoryCache.set(key, token, expiresAt);
}
@@ -78,3 +90,5 @@ class OboSimpleCache {
const hasRedis = REDIS_URI !== undefined && REDIS_USERNAME !== undefined && REDIS_PASSWORD !== undefined;
export const oboCache = hasRedis ? new OboTieredCache(REDIS_URI, REDIS_USERNAME, REDIS_PASSWORD) : new OboSimpleCache();
+
+export const getCacheKey = (navIdent: string, appName: string) => `${navIdent}-${appName}`;
diff --git a/server/src/auth/cache/memory-cache.ts b/server/src/auth/cache/memory-cache.ts
index a33520f63..29eb883c5 100644
--- a/server/src/auth/cache/memory-cache.ts
+++ b/server/src/auth/cache/memory-cache.ts
@@ -14,7 +14,7 @@ export class OboMemoryCache {
tokenMessages.map((tokenMessage) => [tokenMessage.key, [tokenMessage.token, tokenMessage.expiresAt]]),
);
- log.info({ msg: `Created OBO memory cache with ${tokenMessages.length} tokens.` });
+ log.debug({ msg: `Created OBO memory cache with ${tokenMessages.length} tokens.` });
/**
* Clean OBO token cache every 10 minutes.
diff --git a/server/src/auth/cache/redis-cache.ts b/server/src/auth/cache/redis-cache.ts
index b70a9bca6..97dee87e8 100644
--- a/server/src/auth/cache/redis-cache.ts
+++ b/server/src/auth/cache/redis-cache.ts
@@ -122,6 +122,7 @@ export class OboRedisCache {
}
public async set(key: string, token: string, expiresAt: number) {
+ log.debug({ msg: 'Setting OBO token', data: { key, token, expiresAt } });
const json = JSON.stringify({ key, token, expiresAt } satisfies TokenMessage);
this.#client.publish(TOKEN_CHANNEL, json);
diff --git a/server/src/auth/on-behalf-of.ts b/server/src/auth/on-behalf-of.ts
index 046e497c4..26a722d03 100644
--- a/server/src/auth/on-behalf-of.ts
+++ b/server/src/auth/on-behalf-of.ts
@@ -1,28 +1,57 @@
import { Client, GrantBody } from 'openid-client';
import { AZURE_APP_CLIENT_ID, NAIS_CLUSTER_NAME } from '@app/config/config';
import { getLogger } from '@app/logger';
-import { oboCache } from '@app/auth/cache/cache';
-import { createHash } from 'node:crypto';
+import { getCacheKey, oboCache } from '@app/auth/cache/cache';
+import { parseTokenPayload } from '@app/helpers/token-parser';
const log = getLogger('obo-token');
export const getOnBehalfOfAccessToken = async (
authClient: Client,
accessToken: string,
+ navIdent: string,
appName: string,
trace_id: string,
span_id: string,
): Promise => {
- const hash = createHash('sha256').update(accessToken).digest('hex');
- const cacheKey = `${hash}-${appName}`;
+ const cacheKey = getCacheKey(navIdent, appName);
const token = await oboCache.get(cacheKey);
- if (token !== null) {
- return token;
+ if (token === null) {
+ return refreshOnBehalfOfAccessToken(authClient, accessToken, cacheKey, appName, trace_id, span_id);
}
+ const parsed = parseTokenPayload(token);
+
+ if (parsed === undefined) {
+ return refreshOnBehalfOfAccessToken(authClient, accessToken, cacheKey, appName, trace_id, span_id);
+ }
+
+ const now = Math.ceil(Date.now() / 1_000);
+
+ if (parsed.exp < now) {
+ return refreshOnBehalfOfAccessToken(authClient, accessToken, cacheKey, appName, trace_id, span_id);
+ }
+
+ if (parsed.exp - now < 30) {
+ log.debug({ msg: `Refreshing OBO token for ${appName}`, trace_id, span_id, data: { navIdent, appName } });
+
+ refreshOnBehalfOfAccessToken(authClient, accessToken, cacheKey, appName, trace_id, span_id);
+ }
+
+ return token;
+};
+
+export const refreshOnBehalfOfAccessToken = async (
+ authClient: Client,
+ accessToken: string,
+ cacheKey: string,
+ appName: string,
+ trace_id: string,
+ span_id: string,
+): Promise => {
if (typeof authClient.issuer.metadata.token_endpoint !== 'string') {
- const error = new Error(`OpenID issuer misconfigured. Missing token endpoint.`);
+ const error = new Error('OpenID issuer misconfigured. Missing token endpoint.');
log.error({ msg: 'On-Behalf-Of error', error, trace_id, span_id });
throw error;
}
diff --git a/server/src/config/config.ts b/server/src/config/config.ts
index f2696e613..88e96c9f1 100644
--- a/server/src/config/config.ts
+++ b/server/src/config/config.ts
@@ -3,15 +3,26 @@ import { JWK } from 'jose';
import { requiredEnvJson, requiredEnvString } from '@app/config/env-var';
import { isLocal } from '@app/config/env';
+export enum ApiClientEnum {
+ KABAL_API = 'kabal-api',
+ KABAL_SEARCH = 'kabal-search',
+ KABAL_SMART_EDITOR_API = 'kabal-smart-editor-api',
+ KABAL_JSON_TO_PDF = 'kabal-json-to-pdf',
+ KAKA_API = 'kaka-api',
+ KABAL_INNSTILLINGER = 'kabal-innstillinger',
+ KLAGE_KODEVERK_API = 'klage-kodeverk-api',
+ KABAL_TEXT_TEMPLATES = 'kabal-text-templates',
+}
+
export const API_CLIENT_IDS = [
- 'kabal-api',
- 'kabal-search',
- 'kabal-smart-editor-api',
- 'kabal-json-to-pdf',
- 'kaka-api',
- 'kabal-innstillinger',
- 'klage-kodeverk-api',
- 'kabal-text-templates',
+ ApiClientEnum.KABAL_API,
+ ApiClientEnum.KABAL_SEARCH,
+ ApiClientEnum.KABAL_SMART_EDITOR_API,
+ ApiClientEnum.KABAL_JSON_TO_PDF,
+ ApiClientEnum.KAKA_API,
+ ApiClientEnum.KABAL_INNSTILLINGER,
+ ApiClientEnum.KLAGE_KODEVERK_API,
+ ApiClientEnum.KABAL_TEXT_TEMPLATES,
];
const cwd = process.cwd(); // This will be the server folder, as long as the paths in the NPM scripts are not changed.
diff --git a/server/src/functions/guards.ts b/server/src/functions/guards.ts
new file mode 100644
index 000000000..10b67188a
--- /dev/null
+++ b/server/src/functions/guards.ts
@@ -0,0 +1 @@
+export const isNotNull = (value: T | null): value is T => value !== null;
diff --git a/server/src/helpers/mime-type.ts b/server/src/helpers/mime-type.ts
index 8c47b4835..b306e1806 100644
--- a/server/src/helpers/mime-type.ts
+++ b/server/src/helpers/mime-type.ts
@@ -20,6 +20,8 @@ export const getMimeType = (filePath: string): string | undefined => {
return 'image/svg+xml';
case 'ico':
return 'image/x-icon';
+ case 'map':
+ return 'application/json';
default:
return undefined;
}
diff --git a/server/src/helpers/prepare-request-headers.ts b/server/src/helpers/prepare-request-headers.ts
index ade324a86..33c26dde3 100644
--- a/server/src/helpers/prepare-request-headers.ts
+++ b/server/src/helpers/prepare-request-headers.ts
@@ -1,12 +1,6 @@
import { PROXY_VERSION } from '@app/config/config';
import { DEV_DOMAIN, isDeployed } from '@app/config/env';
-import {
- AUTHORIZATION_HEADER,
- AZURE_AD_TOKEN_HEADER,
- CLIENT_VERSION_HEADER,
- PROXY_VERSION_HEADER,
- TAB_ID_HEADER,
-} from '@app/headers';
+import { AUTHORIZATION_HEADER, CLIENT_VERSION_HEADER, PROXY_VERSION_HEADER, TAB_ID_HEADER } from '@app/headers';
import { getLogger } from '@app/logger';
import { FastifyRequest, RawServerBase, RequestGenericInterface } from 'fastify';
@@ -15,8 +9,9 @@ const log = getLogger('prepare-proxy-request-headers');
export const getProxyRequestHeaders = (
req: FastifyRequest,
appName: string,
+ oboAccessToken: string | undefined,
): Record => {
- const { traceparent, client_version, tab_id, accessToken, trace_id, span_id } = req;
+ const { traceparent, client_version, tab_id, trace_id, span_id } = req;
const headers: Record = {
...omit(req.raw.headers, 'set-cookie'),
@@ -33,17 +28,11 @@ export const getProxyRequestHeaders = (
headers[TAB_ID_HEADER] = tab_id;
}
- if (exists(accessToken)) {
- headers[AZURE_AD_TOKEN_HEADER] = accessToken;
- }
-
- const oboAccessToken = req.getOboAccessToken(appName);
-
if (oboAccessToken !== undefined) {
headers[AUTHORIZATION_HEADER] = `Bearer ${oboAccessToken}`;
}
- log.info({
+ log.debug({
msg: 'Prepared proxy request headers',
tab_id,
trace_id,
diff --git a/server/src/helpers/token-parser.ts b/server/src/helpers/token-parser.ts
new file mode 100644
index 000000000..04fdf91cb
--- /dev/null
+++ b/server/src/helpers/token-parser.ts
@@ -0,0 +1,49 @@
+interface TokenPayload {
+ aud: string;
+ iss: string;
+ iat: number;
+ nbf: number;
+ exp: number;
+ aio: string;
+ azp: string;
+ azpacr: string;
+ groups: string[];
+ name: string;
+ oid: string;
+ preferred_username: string;
+ rh: string;
+ scp: string;
+ sub: string;
+ tid: string;
+ uti: string;
+ ver: string;
+ NAVident: string;
+ azp_name: string;
+}
+
+export const parseTokenPayload = (token: string): TokenPayload | undefined => {
+ const payload = token.split('.').at(1);
+
+ if (payload === undefined || payload.length === 0) {
+ return undefined;
+ }
+
+ const decodedPayload = Buffer.from(payload, 'base64').toString('utf-8');
+
+ const parsed: unknown = JSON.parse(decodedPayload);
+
+ if (!isTokenPayload(parsed)) {
+ return undefined;
+ }
+
+ return parsed;
+};
+
+const isTokenPayload = (payload: unknown): payload is TokenPayload =>
+ typeof payload === 'object' &&
+ payload !== null &&
+ 'aud' in payload &&
+ 'iss' in payload &&
+ 'iat' in payload &&
+ 'nbf' in payload &&
+ 'exp' in payload;
diff --git a/server/src/logger.ts b/server/src/logger.ts
index 47c742a90..e99a8835e 100644
--- a/server/src/logger.ts
+++ b/server/src/logger.ts
@@ -22,7 +22,7 @@ export interface AnyObject {
[key: string]: SerializableValue;
}
-type LogArgs =
+export type LogArgs =
| {
msg?: string;
trace_id?: string;
@@ -60,7 +60,7 @@ interface Log extends AnyObject {
stacktrace?: string;
}
-type Level = 'debug' | 'info' | 'warn' | 'error';
+export type Level = 'debug' | 'info' | 'warn' | 'error';
export const getLogger = (module: string): Logger => {
const cachedLogger = LOGGERS.get(module);
diff --git a/server/src/plugins/access-token.ts b/server/src/plugins/access-token.ts
index 1a26e6996..53214219e 100644
--- a/server/src/plugins/access-token.ts
+++ b/server/src/plugins/access-token.ts
@@ -24,7 +24,7 @@ export const accessTokenPlugin = fastifyPlugin(
pluginDone();
},
- { fastify: '4', name: ACCESS_TOKEN_PLUGIN_ID },
+ { fastify: '5', name: ACCESS_TOKEN_PLUGIN_ID },
);
export const getAccessToken = (req: FastifyRequest): string | undefined => {
diff --git a/server/src/plugins/api-proxy.ts b/server/src/plugins/api-proxy.ts
index 6bf8dfaed..6481a73c0 100644
--- a/server/src/plugins/api-proxy.ts
+++ b/server/src/plugins/api-proxy.ts
@@ -37,7 +37,7 @@ export const apiProxyPlugin = fastifyPlugin(
const { method, url, trace_id, span_id, tab_id, client_version, proxyStartTime } = req;
const responseTime = getDuration(proxyStartTime);
- log.info({
+ log.debug({
msg: `Proxy response (${appName}) ${reply.statusCode} ${method} ${url} ${responseTime}ms`,
trace_id,
span_id,
@@ -65,7 +65,7 @@ export const apiProxyPlugin = fastifyPlugin(
websocket: true,
proxyPayloads: true,
preHandler: async (req, reply) => {
- log.info({
+ log.debug({
msg: `Proxy request (${appName}) ${req.method} ${req.url}`,
tab_id: req.tab_id,
trace_id: req.trace_id,
@@ -77,16 +77,19 @@ export const apiProxyPlugin = fastifyPlugin(
url: req.url,
},
});
+
+ // Make sure the OBO token is cached before rewriteRequestHeaders.
+ await req.getOboAccessToken(appName, reply);
+
req.proxyStartTime = performance.now();
- await req.ensureOboAccessToken(appName, reply);
},
retryMethods: ['GET'], // Only retry GET requests. All others are not idempotent.
replyOptions: {
- rewriteRequestHeaders: (req) => getProxyRequestHeaders(req, appName),
+ rewriteRequestHeaders: (req) => getProxyRequestHeaders(req, appName, req.getCachedOboAccessToken(appName)),
rewriteHeaders: (headers, req) => {
const serverTiming = headers[SERVER_TIMING_HEADER];
- const total = `proxy;dur=${req === undefined ? 0 : getDuration(req.proxyStartTime)};desc="Proxy total (${appName})"`;
+ const total = `proxy;dur=${req === undefined ? 0 : getDuration(req.proxyStartTime)};desc="${appName} (proxy)"`;
switch (typeof serverTiming) {
case 'string':
@@ -107,7 +110,7 @@ export const apiProxyPlugin = fastifyPlugin(
.join(', '),
};
default:
- return headers;
+ return { ...headers, [SERVER_TIMING_HEADER]: total };
}
},
},
@@ -116,7 +119,7 @@ export const apiProxyPlugin = fastifyPlugin(
pluginDone();
},
- { fastify: '4', name: 'api-proxy', dependencies: [OBO_ACCESS_TOKEN_PLUGIN_ID, SERVER_TIMING_PLUGIN_ID] },
+ { fastify: '5', name: 'api-proxy', dependencies: [OBO_ACCESS_TOKEN_PLUGIN_ID, SERVER_TIMING_PLUGIN_ID] },
);
const prefixServerTimingEntry = (entry: string, appName: string): string => {
diff --git a/server/src/plugins/client-version.ts b/server/src/plugins/client-version.ts
index c633aebac..0ca3db420 100644
--- a/server/src/plugins/client-version.ts
+++ b/server/src/plugins/client-version.ts
@@ -25,5 +25,5 @@ export const clientVersionPlugin = fastifyPlugin(
pluginDone();
},
- { fastify: '4', name: CLIENT_VERSION_PLUGIN_ID },
+ { fastify: '5', name: CLIENT_VERSION_PLUGIN_ID },
);
diff --git a/server/src/plugins/crdt/api/get-document.ts b/server/src/plugins/crdt/api/get-document.ts
new file mode 100644
index 000000000..d867494bf
--- /dev/null
+++ b/server/src/plugins/crdt/api/get-document.ts
@@ -0,0 +1,99 @@
+import { getLogger } from '@app/logger';
+import { Doc, XmlText, encodeStateAsUpdateV2 } from 'yjs';
+import { slateNodesToInsertDelta } from '@slate-yjs/core';
+import { KABAL_API_URL } from '@app/plugins/crdt/api/url';
+import { isObject } from '@app/plugins/crdt/functions';
+import { Node } from 'slate';
+import { generateTraceparent } from '@app/helpers/traceparent';
+import { ConnectionContext } from '@app/plugins/crdt/context';
+import { getCacheKey, oboCache } from '@app/auth/cache/cache';
+import { ApiClientEnum } from '@app/config/config';
+
+const log = getLogger('collaboration');
+
+export const getDocument = async (context: ConnectionContext): Promise => {
+ const { behandlingId, dokumentId, navIdent, trace_id, span_id, tab_id, client_version } = context;
+ const res = await fetch(`${KABAL_API_URL}/behandlinger/${behandlingId}/dokumenter/${dokumentId}`, {
+ method: 'GET',
+ headers: {
+ accept: 'application/json',
+ authorization: `Bearer ${await oboCache.get(getCacheKey(navIdent, ApiClientEnum.KABAL_API))}`,
+ traceparent: generateTraceparent(trace_id),
+ },
+ });
+
+ if (!res.ok) {
+ const msg = `Failed to fetch document. API responded with status code ${res.status}.`;
+ log.error({
+ msg,
+ trace_id,
+ span_id,
+ tab_id,
+ client_version,
+ data: { behandlingId, dokumentId, statusCode: res.status },
+ });
+ throw new Error(msg);
+ }
+
+ const json = await res.json();
+
+ if (!isDocumentResponse(json)) {
+ const msg = 'Invalid document response';
+ log.error({
+ msg,
+ trace_id,
+ span_id,
+ tab_id,
+ client_version,
+ data: { response: JSON.stringify(json) },
+ });
+
+ throw new Error(msg);
+ }
+
+ const { content, data } = json;
+
+ // If the document has no binary data, create and save it.
+ if (data === null || data.length === 0) {
+ const document = new Doc();
+ const sharedRoot = document.get('content', XmlText);
+ const insertDelta = slateNodesToInsertDelta(content);
+ sharedRoot.applyDelta(insertDelta);
+ const state = encodeStateAsUpdateV2(document);
+ const base64data = Buffer.from(state).toString('base64');
+
+ // Save the binary data to the database.
+ await fetch(`${KABAL_API_URL}/behandlinger/${behandlingId}/smartdokumenter/${dokumentId}`, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ authorization: `Bearer ${await oboCache.get(getCacheKey(navIdent, ApiClientEnum.KABAL_API))}`,
+ traceparent: generateTraceparent(trace_id),
+ },
+ body: JSON.stringify({ content, data: base64data }),
+ });
+
+ // Return the document and binary data.
+ return { content, data: base64data };
+ }
+
+ return { content, data };
+};
+
+interface DocumentResponse {
+ content: Node[];
+ data: string;
+}
+
+interface ApiDocumentResponse {
+ content: Node[];
+ data: string | null;
+}
+
+export const isDocumentResponse = (data: unknown): data is ApiDocumentResponse =>
+ isObject(data) &&
+ 'isSmartDokument' in data &&
+ data.isSmartDokument === true &&
+ 'content' in data &&
+ Array.isArray(data.content) &&
+ 'data' in data;
diff --git a/server/src/plugins/crdt/api/headers.ts b/server/src/plugins/crdt/api/headers.ts
new file mode 100644
index 000000000..daabe75dd
--- /dev/null
+++ b/server/src/plugins/crdt/api/headers.ts
@@ -0,0 +1,56 @@
+import { FastifyRequest } from 'fastify';
+import { CLIENT_VERSION_HEADER, PROXY_VERSION_HEADER, TAB_ID_HEADER } from '@app/headers';
+import { ApiClientEnum, PROXY_VERSION } from '@app/config/config';
+import { isDeployed } from '@app/config/env';
+import { generateTraceparent } from '@app/helpers/traceparent';
+
+type GetHeadersFn = (req: FastifyRequest) => Promise>;
+
+const getDeployedHeaders: GetHeadersFn = async (req) => {
+ const { client_version, tab_id, traceparent } = req;
+
+ return {
+ Authorization: `Bearer ${await req.getOboAccessToken(ApiClientEnum.KABAL_API)}`,
+ Accept: 'application/json',
+ traceparent,
+ [PROXY_VERSION_HEADER]: PROXY_VERSION,
+ [CLIENT_VERSION_HEADER]: client_version,
+ [TAB_ID_HEADER]: tab_id,
+ };
+};
+
+const IGNORED_HEADERS = [
+ 'host',
+ 'origin',
+ 'connection',
+ 'upgrade',
+ 'sec-websocket-version',
+ 'sec-websocket-extensions',
+ 'sec-websocket-version',
+ 'sec-websocket-key',
+ 'content-length',
+];
+
+const getLocalHeaders: GetHeadersFn = async (req) => {
+ const headers: Record = {
+ host: 'kabal.intern.dev.nav.no',
+ origin: 'https://kabal.intern.dev.nav.no',
+ };
+
+ for (const [key, value] of Object.entries(req.headers)) {
+ if (value !== undefined && !IGNORED_HEADERS.includes(key)) {
+ headers[key] = value;
+ }
+ }
+
+ if ('traceparent' in req.headers) {
+ return headers;
+ }
+
+ return {
+ ...headers,
+ traceparent: generateTraceparent(),
+ };
+};
+
+export const getHeaders: GetHeadersFn = isDeployed ? getDeployedHeaders : getLocalHeaders;
diff --git a/server/src/plugins/crdt/api/set-document.ts b/server/src/plugins/crdt/api/set-document.ts
new file mode 100644
index 000000000..c65da470d
--- /dev/null
+++ b/server/src/plugins/crdt/api/set-document.ts
@@ -0,0 +1,48 @@
+import { getCacheKey, oboCache } from '@app/auth/cache/cache';
+import { ApiClientEnum } from '@app/config/config';
+import { generateTraceparent } from '@app/helpers/traceparent';
+import { getLogger } from '@app/logger';
+import { KABAL_API_URL } from '@app/plugins/crdt/api/url';
+import { ConnectionContext } from '@app/plugins/crdt/context';
+import { Document } from '@hocuspocus/server';
+import { yTextToSlateElement } from '@slate-yjs/core';
+import { XmlText, encodeStateAsUpdateV2 } from 'yjs';
+
+const log = getLogger('collaboration');
+
+export const setDocument = async (context: ConnectionContext, document: Document) => {
+ const { behandlingId, dokumentId, navIdent, trace_id, span_id, tab_id, client_version } = context;
+ const update = Buffer.from(encodeStateAsUpdateV2(document));
+ const data = update.toString('base64url');
+
+ const sharedRoot = document.get('content', XmlText);
+ const nodes = yTextToSlateElement(sharedRoot);
+
+ const res = await fetch(`${KABAL_API_URL}/behandlinger/${behandlingId}/smartdokumenter/${dokumentId}`, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ authorization: `Bearer ${await oboCache.get(getCacheKey(navIdent, ApiClientEnum.KABAL_API))}`,
+ traceparent: generateTraceparent(trace_id),
+ },
+ body: JSON.stringify({ content: nodes.children, data }),
+ });
+
+ if (!res.ok) {
+ const msg = `Failed to save document. API responded with status code ${res.status}.`;
+ const text = await res.text();
+
+ log.error({
+ msg,
+ trace_id,
+ span_id,
+ tab_id,
+ client_version,
+ data: { behandlingId, dokumentId, statusCode: res.status, response: text },
+ });
+
+ throw new Error(`${msg} - ${text}`);
+ }
+
+ return res;
+};
diff --git a/server/src/plugins/crdt/api/url.ts b/server/src/plugins/crdt/api/url.ts
new file mode 100644
index 000000000..afabac97b
--- /dev/null
+++ b/server/src/plugins/crdt/api/url.ts
@@ -0,0 +1,3 @@
+import { isDeployed } from '@app/config/env';
+
+export const KABAL_API_URL = isDeployed ? 'http://kabal-api' : 'https://kabal.intern.dev.nav.no/api/kabal-api';
diff --git a/server/src/plugins/crdt/collaboration-server.ts b/server/src/plugins/crdt/collaboration-server.ts
new file mode 100644
index 000000000..8e9d59b51
--- /dev/null
+++ b/server/src/plugins/crdt/collaboration-server.ts
@@ -0,0 +1,124 @@
+import { isDeployed } from '@app/config/env';
+import { isNotNull } from '@app/functions/guards';
+import { parseTokenPayload } from '@app/helpers/token-parser';
+import { Level, getLogger } from '@app/logger';
+import { getDocument } from '@app/plugins/crdt/api/get-document';
+import { setDocument } from '@app/plugins/crdt/api/set-document';
+import { ConnectionContext, isConnectionContext } from '@app/plugins/crdt/context';
+import { getRedisExtension } from '@app/plugins/crdt/redis';
+import { Server } from '@hocuspocus/server';
+import { CloseEvent } from '@hocuspocus/common';
+import { applyUpdateV2 } from 'yjs';
+import { getCacheKey, oboCache } from '@app/auth/cache/cache';
+import { ApiClientEnum } from '@app/config/config';
+
+const log = getLogger('collaboration');
+
+const logContext = (msg: string, context: ConnectionContext, level: Level = 'info') => {
+ log[level]({
+ msg,
+ trace_id: context.trace_id,
+ span_id: context.span_id,
+ tab_id: context.tab_id,
+ client_version: context.client_version,
+ data: { behandlingId: context.behandlingId, dokumentId: context.dokumentId },
+ });
+};
+
+export const collaborationServer = Server.configure({
+ debounce: 3_000,
+ maxDebounce: 15_000,
+
+ onConnect: async ({ context }) => {
+ if (isConnectionContext(context)) {
+ // navIdent is not defined when server is run without Wonderwall (ie. locally).
+ logContext(`Collaboration connection established for ${context.navIdent}!`, context);
+ } else {
+ log.error({ msg: 'Tried to establish collaboration connection without context' });
+ throw new Error('Invalid context');
+ }
+ },
+
+ onDisconnect: async ({ context }) => {
+ if (isConnectionContext(context)) {
+ // navIdent is not defined locally.
+ logContext(`Collaboration connection closed for ${context.navIdent}!`, context);
+ } else {
+ log.error({ msg: 'Tried to close collaboration connection without context' });
+ throw new Error('Invalid context');
+ }
+ },
+
+ beforeHandleMessage: async ({ context }) => {
+ if (!isDeployed) {
+ return;
+ }
+
+ if (!isConnectionContext(context)) {
+ log.error({ msg: 'Tried to handle message without context' });
+
+ throw getCloseEvent('INVALID_CONTEXT', 4401);
+ }
+
+ const { navIdent } = context;
+ const oboAccessToken = await oboCache.get(getCacheKey(navIdent, ApiClientEnum.KABAL_API));
+
+ if (oboAccessToken === null) {
+ logContext('No OBO token', context, 'warn');
+ throw getCloseEvent('MISSING_OBO_TOKEN', 4403);
+ }
+
+ const parsedPayload = parseTokenPayload(oboAccessToken);
+
+ if (parsedPayload === undefined) {
+ logContext(`Invalid OBO token payload. oboAccessToken: ${oboAccessToken}`, context, 'warn');
+ throw getCloseEvent('INVALID_OBO_TOKEN', 4403);
+ }
+
+ const { exp } = parsedPayload;
+ const now = Math.ceil(Date.now() / 1000);
+
+ if (exp <= now) {
+ logContext(`OBO token expired. exp: ${exp}, now: ${now} `, context, 'warn');
+ throw getCloseEvent('OBO_TOKEN_EXPIRED', 4403);
+ }
+ },
+
+ onLoadDocument: async ({ context, document }) => {
+ if (!isConnectionContext(context)) {
+ log.error({ msg: 'Tried to load document without context' });
+ throw new Error('Invalid context');
+ }
+
+ if (!document.isEmpty('content')) {
+ logContext('Document already loaded', context);
+
+ return document;
+ }
+
+ const res = await getDocument(context);
+
+ logContext('Loaded state/update', context, 'debug');
+
+ const update = new Uint8Array(Buffer.from(res.data, 'base64url'));
+
+ applyUpdateV2(document, update);
+
+ logContext('Loaded state/update applied', context, 'debug');
+ },
+
+ onStoreDocument: async ({ context, document }) => {
+ if (!isConnectionContext(context)) {
+ log.error({ msg: 'Tried to store document without context' });
+ throw new Error('Invalid context');
+ }
+
+ await setDocument(context, document);
+
+ logContext('Saved document to database', context, 'debug');
+ },
+
+ extensions: isDeployed ? [getRedisExtension()].filter(isNotNull) : [],
+});
+
+const getCloseEvent = (reason: string, code: number): CloseEvent => ({ reason, code });
diff --git a/server/src/plugins/crdt/context.ts b/server/src/plugins/crdt/context.ts
new file mode 100644
index 000000000..1be62d337
--- /dev/null
+++ b/server/src/plugins/crdt/context.ts
@@ -0,0 +1,14 @@
+import { isObject } from '@app/plugins/crdt/functions';
+
+export interface ConnectionContext {
+ readonly behandlingId: string;
+ readonly dokumentId: string;
+ readonly trace_id?: string;
+ readonly span_id?: string;
+ readonly tab_id?: string;
+ readonly client_version?: string;
+ readonly navIdent: string;
+}
+
+export const isConnectionContext = (data: unknown): data is ConnectionContext =>
+ isObject(data) && 'behandlingId' in data && 'dokumentId' in data;
diff --git a/server/src/plugins/crdt/crdt.ts b/server/src/plugins/crdt/crdt.ts
new file mode 100644
index 000000000..40814cefd
--- /dev/null
+++ b/server/src/plugins/crdt/crdt.ts
@@ -0,0 +1,276 @@
+import { isDeployed } from '@app/config/env';
+import { AnyObject, Level, LogArgs, getLogger } from '@app/logger';
+import { ACCESS_TOKEN_PLUGIN_ID } from '@app/plugins/access-token';
+import * as Y from 'yjs';
+import { OBO_ACCESS_TOKEN_PLUGIN_ID } from '@app/plugins/obo-token';
+import { TAB_ID_PLUGIN_ID } from '@app/plugins/tab-id';
+import { TRACEPARENT_PLUGIN_ID } from '@app/plugins/traceparent/traceparent';
+import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
+import { slateNodesToInsertDelta } from '@slate-yjs/core';
+import fastifyPlugin from 'fastify-plugin';
+import { KABAL_API_URL } from '@app/plugins/crdt/api/url';
+import { getHeaders } from '@app/plugins/crdt/api/headers';
+import { isObject } from '@app/plugins/crdt/functions';
+import { FastifyRequest } from 'fastify/types/request';
+import { Socket } from 'node:net';
+import WebSocket from 'ws';
+import { collaborationServer } from '@app/plugins/crdt/collaboration-server';
+import { ConnectionContext } from '@app/plugins/crdt/context';
+import { IncomingMessage } from 'node:http';
+import { Duplex } from 'node:stream';
+import { parseTokenPayload } from '@app/helpers/token-parser';
+import { NAV_IDENT_PLUGIN_ID } from '@app/plugins/nav-ident';
+import { getAzureADClient } from '@app/auth/get-auth-client';
+import { getCacheKey } from '@app/auth/cache/cache';
+import { refreshOnBehalfOfAccessToken } from '@app/auth/on-behalf-of';
+import { ApiClientEnum } from '@app/config/config';
+
+const UPGRADE_MAP: Map = new Map();
+const UPGRADE_TIMEOUT = 1_000;
+
+export const CRDT_PLUGIN_ID = 'crdt';
+
+const log = getLogger(CRDT_PLUGIN_ID);
+
+const logReq = (msg: string, req: FastifyRequest, data: AnyObject, level: Level = 'info', error?: unknown) => {
+ const { trace_id, span_id, tab_id, client_version } = req;
+ const body: LogArgs = { msg, trace_id, span_id, tab_id, client_version, data };
+
+ if (error !== undefined) {
+ body.error = error;
+ }
+
+ log[level](body);
+};
+
+export const crdtPlugin = fastifyPlugin(
+ async (app) => {
+ app.withTypeProvider().post(
+ '/collaboration/behandlinger/:behandlingId/dokumenter',
+ {
+ schema: {
+ tags: ['collaboration'],
+ params: Type.Object({ behandlingId: Type.String() }),
+ body: Type.Object({
+ content: Type.Array(
+ Type.Recursive((This) =>
+ Type.Union([
+ Type.Object({ text: Type.String() }),
+ Type.Object({
+ children: Type.Array(This),
+ }),
+ ]),
+ ),
+ ),
+ templateId: Type.String(),
+ tittel: Type.String(),
+ dokumentTypeId: Type.String(),
+ parentId: Type.String(),
+ language: Type.String(),
+ }),
+ produces: ['application/json'],
+ },
+ },
+ async (req, reply) => {
+ const { behandlingId } = req.params;
+ logReq('Creating new collaboration document', req, { behandlingId });
+
+ const { body } = req;
+
+ if (!isObject(body) || !('content' in body) || !Array.isArray(body.content)) {
+ logReq('Invalid request body', req, { behandlingId }, 'error');
+
+ return reply.status(400).send();
+ }
+
+ try {
+ const { content } = body;
+ const document = new Y.Doc();
+ const sharedRoot = document.get('content', Y.XmlText);
+ const insertDelta = slateNodesToInsertDelta(content);
+ sharedRoot.applyDelta(insertDelta);
+ const state = Y.encodeStateAsUpdateV2(document);
+ const data = Buffer.from(state).toString('base64');
+
+ logReq('Saving new document to database', req, { behandlingId, data });
+
+ const headers = await getHeaders(req);
+
+ const res = await fetch(`${KABAL_API_URL}/behandlinger/${behandlingId}/smartdokumenter`, {
+ method: 'POST',
+ headers: { ...headers, 'content-type': 'application/json' },
+ body: JSON.stringify({ ...body, data }),
+ });
+
+ if (!res.ok) {
+ const msg = `Failed to save document. API responded with status code ${res.status}`;
+ logReq(msg, req, { behandlingId, statusCode: res.status }, 'error');
+ } else {
+ logReq('Saved new document to database', req, { behandlingId }, 'debug');
+ }
+
+ return reply.send(res);
+ } catch (error) {
+ logReq('Failed to save document', req, { behandlingId }, 'error', error);
+
+ return reply.status(500).send();
+ }
+ },
+ );
+
+ const wss = new WebSocket.Server({ noServer: true });
+
+ app.server.on('upgrade', (rawRequest, socket, head) => {
+ UPGRADE_MAP.set(rawRequest, { socket, head });
+
+ // Make sure the upgrade request is deleted, even if the handler does not.
+ setTimeout(() => {
+ if (UPGRADE_MAP.has(rawRequest)) {
+ log.warn({ msg: 'Upgrade request timed out' });
+ UPGRADE_MAP.delete(rawRequest);
+ }
+ }, UPGRADE_TIMEOUT);
+ });
+
+ app.withTypeProvider().get(
+ '/collaboration/behandlinger/:behandlingId/dokumenter/:dokumentId',
+ {
+ schema: {
+ tags: ['collaboration'],
+ params: Type.Object({ behandlingId: Type.String(), dokumentId: Type.String() }),
+ },
+ },
+ async (req, reply) => {
+ const upgradeData = UPGRADE_MAP.get(req.raw);
+ UPGRADE_MAP.delete(req.raw);
+
+ if (upgradeData === undefined) {
+ return reply.code(400).send('No upgrade data found');
+ }
+
+ const { behandlingId, dokumentId } = req.params;
+ logReq('Websocket connection init', req, { behandlingId, dokumentId });
+
+ const oboAccessToken = await req.getOboAccessToken(ApiClientEnum.KABAL_API, reply);
+
+ if (isDeployed && oboAccessToken === undefined) {
+ const msg = 'Tried to authenticate collaboration connection without OBO access token';
+ logReq(msg, req, { behandlingId, dokumentId }, 'warn');
+
+ return reply.code(401).send('Unauthorized');
+ }
+
+ const { socket, head } = upgradeData;
+
+ try {
+ const webSocket = await new Promise((resolve, reject) => {
+ wss.handleUpgrade(req.raw, socket, head, (ws) => {
+ wss.emit('connection', socket, req.raw);
+
+ socket.on('error', (error) => {
+ app.log.error(error);
+ reject(error);
+ });
+
+ resolve(ws);
+ });
+ });
+
+ reply.raw.assignSocket(new Socket(socket));
+
+ reply.hijack();
+
+ logReq('Handing over connection to HocusPocus', req, { behandlingId, dokumentId });
+
+ const { navIdent, trace_id, span_id, tab_id, client_version } = req;
+
+ const context: ConnectionContext = {
+ behandlingId,
+ dokumentId,
+ trace_id,
+ span_id,
+ tab_id,
+ client_version,
+ navIdent,
+ };
+
+ collaborationServer.handleConnection(webSocket, req.raw, context);
+ } catch (e) {
+ reply.code(500).send(e instanceof Error ? e.message : 'Internal Server Error');
+ console.error(e);
+ }
+ },
+ );
+
+ app.withTypeProvider().get(
+ '/collaboration/obo-token-exp',
+ {
+ schema: {
+ response: { 200: Type.Object({ expiresIn: Type.Number() }), 401: Type.String() },
+ },
+ },
+ async (req, reply) => {
+ const oboAccessToken = await req.getOboAccessToken(ApiClientEnum.KABAL_API);
+
+ if (oboAccessToken === undefined) {
+ return reply.status(401).send('Unauthorized');
+ }
+
+ const parsedToken = parseTokenPayload(oboAccessToken);
+
+ if (parsedToken === undefined) {
+ return reply.send({ expiresIn: 0 });
+ }
+
+ const now = Math.ceil(Date.now() / 1_000);
+
+ return reply.status(200).send({ expiresIn: parsedToken.exp - now });
+ },
+ );
+
+ app.withTypeProvider().get(
+ '/collaboration/refresh-obo-access-token',
+ {
+ schema: {
+ response: { 200: Type.Object({ expiresIn: Type.Number() }), 400: Type.String() },
+ },
+ },
+ async (req, reply) => {
+ const { navIdent, accessToken, trace_id, span_id } = req;
+
+ const authClient = await getAzureADClient();
+ const cacheKey = getCacheKey(navIdent, ApiClientEnum.KABAL_API);
+
+ const oboAccessToken = await refreshOnBehalfOfAccessToken(
+ authClient,
+ accessToken,
+ cacheKey,
+ ApiClientEnum.KABAL_API,
+ trace_id,
+ span_id,
+ );
+
+ const parsed = parseTokenPayload(oboAccessToken);
+
+ if (parsed === undefined) {
+ return reply.status(400).send('Failed to refresh OBO token');
+ }
+
+ const now = Math.ceil(Date.now() / 1_000);
+
+ return reply.status(200).send({ expiresIn: parsed.exp - now });
+ },
+ );
+ },
+ {
+ fastify: '5',
+ name: CRDT_PLUGIN_ID,
+ dependencies: [
+ ACCESS_TOKEN_PLUGIN_ID,
+ OBO_ACCESS_TOKEN_PLUGIN_ID,
+ TRACEPARENT_PLUGIN_ID,
+ TAB_ID_PLUGIN_ID,
+ NAV_IDENT_PLUGIN_ID,
+ ],
+ },
+);
diff --git a/server/src/plugins/crdt/functions.ts b/server/src/plugins/crdt/functions.ts
new file mode 100644
index 000000000..35af4d454
--- /dev/null
+++ b/server/src/plugins/crdt/functions.ts
@@ -0,0 +1 @@
+export const isObject = (data: unknown): data is object => typeof data === 'object' && data !== null;
diff --git a/server/src/plugins/crdt/redis-extension/redis-extension.ts b/server/src/plugins/crdt/redis-extension/redis-extension.ts
new file mode 100644
index 000000000..ef66eb44c
--- /dev/null
+++ b/server/src/plugins/crdt/redis-extension/redis-extension.ts
@@ -0,0 +1,340 @@
+import { getLogger } from '@app/logger';
+import { RedisOptions } from '@app/plugins/crdt/redis-extension/types';
+import {
+ Debugger,
+ Document,
+ Extension,
+ Hocuspocus,
+ IncomingMessage,
+ MessageReceiver,
+ OutgoingMessage,
+ afterLoadDocumentPayload,
+ afterStoreDocumentPayload,
+ beforeBroadcastStatelessPayload,
+ onAwarenessUpdatePayload,
+ onChangePayload,
+ onConfigurePayload,
+ onDisconnectPayload,
+} from '@hocuspocus/server';
+import { randomUUID } from 'node:crypto';
+import { RedisClientType, createClient } from 'redis';
+
+const log = getLogger('redis-extension');
+
+export class RedisExtension implements Extension {
+ readonly #prefix = 'hocuspocus';
+ readonly #identifier = `host-${randomUUID()}`;
+ readonly #disconnectDelay = 1_000;
+ readonly #redisConnectionRetryDelay = 100;
+ readonly #redisTransactionOrigin = '__hocuspocus__redis__origin__';
+ readonly #pub: RedisClientType;
+ readonly #sub: RedisClientType;
+ readonly #messagePrefix: Buffer;
+ instance?: Hocuspocus;
+
+ #isReady = false;
+
+ public constructor(options: RedisOptions) {
+ log.debug({ msg: 'Creating RedisExtension', data: { identifier: this.#identifier } });
+
+ this.#pub = createClient({ ...options, pingInterval: 30_000 });
+ this.#sub = this.#pub.duplicate();
+
+ this.#pub.on('error', (error) =>
+ log.error({ msg: 'Redis publish client error', error, data: { identifier: this.#identifier } }),
+ );
+ this.#sub.on('error', (error) =>
+ log.error({ msg: 'Redis subscribe client error', error, data: { identifier: this.#identifier } }),
+ );
+
+ this.#init();
+
+ const identifierBuffer = Buffer.from(this.#identifier, 'utf-8');
+ this.#messagePrefix = Buffer.concat([Buffer.from([identifierBuffer.length]), identifierBuffer]);
+ }
+
+ async #init() {
+ await Promise.all([this.#sub.connect(), this.#pub.connect()]);
+
+ this.#isReady = true;
+ }
+
+ async onConfigure({ instance }: onConfigurePayload) {
+ log.debug({ msg: 'Configuring RedisExtension', data: { identifier: this.#identifier } });
+ this.instance = instance;
+ }
+
+ #getKey = (documentName: string) => `${this.#prefix}:${documentName}`;
+
+ #encodeMessage = (message: Uint8Array) => Buffer.concat([this.#messagePrefix, Buffer.from(message)]);
+
+ #decodeMessage(buffer: Buffer): [string, Buffer] {
+ const [identifierLength] = buffer;
+
+ if (identifierLength === undefined) {
+ throw new Error('Invalid message received');
+ }
+
+ const messageStart = identifierLength + 1;
+ const identifier = buffer.toString('utf-8', 1, messageStart);
+
+ return [identifier, buffer.subarray(messageStart)];
+ }
+
+ public async afterLoadDocument(params: afterLoadDocumentPayload): Promise {
+ const { documentName, document } = params;
+
+ log.debug({
+ msg: `Subscribing to document: ${documentName}`,
+ data: { document: documentName, identifier: this.#identifier },
+ });
+
+ if (!this.#isReady) {
+ log.warn({
+ msg: 'Redis is not ready',
+ data: { document: documentName, method: 'afterLoadDocument', identifier: this.#identifier },
+ });
+
+ await delay(this.#redisConnectionRetryDelay);
+
+ return this.afterLoadDocument(params);
+ }
+
+ if (this.instance !== undefined && !this.instance.documents.has(documentName)) {
+ log.warn({
+ msg: 'Loaded document was not in documents map. Adding to documents map.',
+ data: { document: documentName, method: 'afterLoadDocument', identifier: this.#identifier },
+ });
+
+ this.instance?.documents.set(documentName, document);
+ }
+
+ // On document creation the node will connect to pub and sub channels for the document.
+ await this.#sub.subscribe(
+ this.#getKey(documentName),
+ async (msg) => this.#handleIncomingMessage(msg, document),
+ true,
+ );
+
+ await Promise.all([
+ this.#publishFirstSyncStep(documentName, document),
+ this.#requestAwarenessFromOtherInstances(documentName),
+ ]);
+ }
+
+ async #handleIncomingMessage(data: Buffer, document: Document) {
+ const [identifier, messageBuffer] = this.#decodeMessage(data);
+
+ if (identifier === this.#identifier) {
+ return;
+ }
+
+ const message = new IncomingMessage(messageBuffer);
+ const documentName = message.readVarString();
+ message.writeVarString(documentName);
+
+ if (this.instance === undefined) {
+ log.warn({
+ msg: 'HocusPocus instance is undefined',
+ data: { document: documentName, method: 'handleIncomingMessage', identifier },
+ });
+
+ return;
+ }
+
+ if (!this.instance.documents.has(documentName)) {
+ log.warn({
+ msg: 'Received message for document not in map',
+ data: { document: documentName, method: 'handleIncomingMessage', identifier },
+ });
+ }
+
+ if (document === undefined) {
+ // Received message for unknown document. Should not happen.
+ const knownDocuments = Array.from(this.instance.documents.keys()).join(', ');
+
+ log.error({
+ msg: `${identifier} Received message for unknown document "${documentName}". Known documents "${knownDocuments}"`,
+ data: {
+ document: documentName,
+ knownDocuments,
+ knownDocumentCount: this.instance.documents.size,
+ identifier,
+ },
+ });
+
+ return;
+ }
+
+ new MessageReceiver(message, new Debugger(), this.#redisTransactionOrigin).apply(document, undefined, (reply) =>
+ this.#pub.publish(this.#getKey(document.name), this.#encodeMessage(reply)),
+ );
+ }
+
+ async #publishFirstSyncStep(documentName: string, document: Document): Promise {
+ if (!this.#isReady) {
+ log.warn({
+ msg: 'Redis is not ready',
+ data: { document: documentName, method: 'publishFirstSyncStep', identifier: this.#identifier },
+ });
+
+ await delay(this.#redisConnectionRetryDelay);
+
+ return this.#publishFirstSyncStep(documentName, document);
+ }
+
+ const syncMessage = new OutgoingMessage(documentName).createSyncMessage().writeFirstSyncStepFor(document);
+
+ return this.#pub.publish(this.#getKey(documentName), this.#encodeMessage(syncMessage.toUint8Array()));
+ }
+
+ async #requestAwarenessFromOtherInstances(documentName: string): Promise {
+ if (!this.#isReady) {
+ log.warn({
+ msg: 'Redis is not ready',
+ data: { document: documentName, method: 'requestAwarenessFromOtherInstances', identifier: this.#identifier },
+ });
+
+ await delay(this.#redisConnectionRetryDelay);
+
+ return this.#requestAwarenessFromOtherInstances(documentName);
+ }
+
+ const awarenessMessage = new OutgoingMessage(documentName).writeQueryAwareness();
+
+ return this.#pub.publish(this.#getKey(documentName), this.#encodeMessage(awarenessMessage.toUint8Array()));
+ }
+
+ async afterStoreDocument({ socketId, documentName }: afterStoreDocumentPayload) {
+ log.debug({
+ msg: `afterStoreDocument - socket: ${socketId}`,
+ data: { document: documentName, identifier: this.#identifier },
+ });
+
+ // If the change was initiated by a directConnection, we need to delay this hook to make sure sync can finish first.
+ // For provider connections, this usually happens in the onDisconnect hook.
+ if (socketId === 'server' && this.#disconnectDelay > 0) {
+ await delay(this.#disconnectDelay);
+ }
+ }
+
+ async onAwarenessUpdate(params: onAwarenessUpdatePayload): Promise {
+ const { documentName, awareness, added, updated, removed } = params;
+ log.debug({
+ msg: `onAwarenessUpdate - document: ${documentName}`,
+ data: { document: documentName, identifier: this.#identifier },
+ });
+
+ if (!this.#isReady) {
+ log.warn({
+ msg: 'Redis is not ready',
+ data: { document: documentName, method: 'onAwarenessUpdate', identifier: this.#identifier },
+ });
+
+ await delay(this.#redisConnectionRetryDelay);
+
+ return this.onAwarenessUpdate(params);
+ }
+
+ const changedClients = added.concat(updated, removed);
+ const message = new OutgoingMessage(documentName).createAwarenessUpdateMessage(awareness, changedClients);
+
+ return this.#pub.publish(this.#getKey(documentName), this.#encodeMessage(message.toUint8Array()));
+ }
+
+ public async onChange({ documentName, document, transactionOrigin }: onChangePayload): Promise {
+ log.debug({
+ msg: `onChange - document: ${documentName}`,
+ data: { document: documentName, identifier: this.#identifier },
+ });
+
+ if (transactionOrigin === this.#redisTransactionOrigin) {
+ return;
+ }
+
+ this.#publishFirstSyncStep(documentName, document);
+ }
+
+ public async onDisconnect({ documentName, document }: onDisconnectPayload): Promise {
+ log.debug({
+ msg: `onDisconnect - document: ${documentName}`,
+ data: { document: documentName, identifier: this.#identifier },
+ });
+
+ // Delay the disconnect procedure to allow last minute syncs to happen.
+ setTimeout(() => this.#disconnect(documentName, document), this.#disconnectDelay);
+ }
+
+ async #disconnect(documentName: string, document: Document): Promise {
+ if (this.instance === undefined) {
+ log.warn({
+ msg: 'HocusPocus instance is undefined',
+ data: { document: documentName, method: 'disconnect', identifier: this.#identifier },
+ });
+
+ try {
+ // Time to end the subscription on the document channel.
+ await this.#sub.unsubscribe(this.#getKey(documentName));
+ } catch (error) {
+ log.error({
+ msg: `Failed to unsubscribe from document: "${documentName}"`,
+ error,
+ data: { document: documentName, identifier: this.#identifier },
+ });
+ }
+
+ return;
+ }
+
+ // Do nothing when other users are still connected to the document.
+ if (document.getConnectionsCount() > 0) {
+ return;
+ }
+
+ try {
+ // Time to end the subscription on the document channel.
+ await this.#sub.unsubscribe(this.#getKey(documentName));
+ } catch (error) {
+ log.error({
+ msg: `Failed to unsubscribe from document: "${documentName}"`,
+ error,
+ data: { document: documentName, identifier: this.#identifier },
+ });
+ }
+
+ this.instance.unloadDocument(document);
+ }
+
+ public async beforeBroadcastStateless(data: beforeBroadcastStatelessPayload): Promise {
+ const { documentName } = data;
+
+ log.debug({
+ msg: `beforeBroadcastStateless - document: ${documentName}`,
+ data: { document: documentName, identifier: this.#identifier },
+ });
+
+ if (!this.#isReady) {
+ log.warn({
+ msg: 'Redis is not ready',
+ data: { document: documentName, method: 'beforeBroadcastStateless', identifier: this.#identifier },
+ });
+
+ await delay(this.#redisConnectionRetryDelay);
+
+ return this.beforeBroadcastStateless(data);
+ }
+
+ const message = new OutgoingMessage(documentName).writeBroadcastStateless(data.payload);
+
+ this.#pub.publish(this.#getKey(documentName), this.#encodeMessage(message.toUint8Array()));
+ }
+
+ public async onDestroy() {
+ log.debug({ msg: 'Destroying RedisExtension', data: { identifier: this.#identifier } });
+
+ this.#pub.disconnect();
+ this.#sub.disconnect();
+ }
+}
+
+const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
diff --git a/server/src/plugins/crdt/redis-extension/types.ts b/server/src/plugins/crdt/redis-extension/types.ts
new file mode 100644
index 000000000..677bbc7f0
--- /dev/null
+++ b/server/src/plugins/crdt/redis-extension/types.ts
@@ -0,0 +1,17 @@
+export interface RedisOptions {
+ /**
+ * The URL of the Redis server.
+ * @example 'redis://localhost:6379'
+ */
+ readonly url: string;
+ /**
+ * The username for the Redis server.
+ * @example 'username'
+ */
+ readonly username?: string;
+ /**
+ * The password for the Redis server.
+ * @example 'password'
+ */
+ readonly password?: string;
+}
diff --git a/server/src/plugins/crdt/redis.ts b/server/src/plugins/crdt/redis.ts
new file mode 100644
index 000000000..fc82251f3
--- /dev/null
+++ b/server/src/plugins/crdt/redis.ts
@@ -0,0 +1,26 @@
+import { optionalEnvString } from '@app/config/env-var';
+import { getLogger } from '@app/logger';
+import { RedisExtension } from '@app/plugins/crdt/redis-extension/redis-extension';
+
+const log = getLogger('collaboration');
+
+const REDIS_URI = optionalEnvString('REDIS_URI_HOCUSPOCUS');
+const REDIS_USERNAME = optionalEnvString('REDIS_USERNAME_HOCUSPOCUS');
+const REDIS_PASSWORD = optionalEnvString('REDIS_PASSWORD_HOCUSPOCUS');
+
+export const getRedisExtension = () => {
+ const hasRedis = REDIS_URI !== undefined && REDIS_USERNAME !== undefined && REDIS_PASSWORD !== undefined;
+
+ if (!hasRedis) {
+ log.error({ msg: 'No collaboration Redis connection configured' });
+ process.exit(1);
+ }
+
+ log.info({ msg: 'Collaboration Redis connection configured' });
+
+ return new RedisExtension({
+ url: REDIS_URI,
+ username: REDIS_USERNAME,
+ password: REDIS_PASSWORD,
+ });
+};
diff --git a/server/src/plugins/document.ts b/server/src/plugins/document.ts
index dfd4d9439..507367450 100644
--- a/server/src/plugins/document.ts
+++ b/server/src/plugins/document.ts
@@ -9,6 +9,7 @@ import fastifyPlugin from 'fastify-plugin';
import { Static, Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { SERVER_TIMING_HEADER, SERVER_TIMING_PLUGIN_ID } from '@app/plugins/server-timing';
import { OBO_ACCESS_TOKEN_PLUGIN_ID } from '@app/plugins/obo-token';
+import { ApiClientEnum } from '@app/config/config';
interface IBaseMetadata {
title: string;
@@ -195,7 +196,7 @@ export const documentPlugin = fastifyPlugin(
pluginDone();
},
- { fastify: '4', name: 'document-routes', dependencies: [OBO_ACCESS_TOKEN_PLUGIN_ID, SERVER_TIMING_PLUGIN_ID] },
+ { fastify: '5', name: 'document-routes', dependencies: [OBO_ACCESS_TOKEN_PLUGIN_ID, SERVER_TIMING_PLUGIN_ID] },
);
const send = (reply: FastifyReply, url: string, documentIdList: string, title: string) => {
@@ -213,9 +214,9 @@ const getMetadata = async (
req: FastifyRequest,
reply: FastifyReply,
): Promise => {
- await req.ensureOboAccessToken('kabal-api', reply);
+ const oboAccessToken = await req.getOboAccessToken(ApiClientEnum.KABAL_API, reply);
- const headers = getProxyRequestHeaders(req, 'kabal-api');
+ const headers = getProxyRequestHeaders(req, ApiClientEnum.KABAL_API, oboAccessToken);
const metadataReqStart = performance.now();
diff --git a/server/src/plugins/health.ts b/server/src/plugins/health.ts
index f5577be9e..bcc48b4a4 100644
--- a/server/src/plugins/health.ts
+++ b/server/src/plugins/health.ts
@@ -37,5 +37,5 @@ export const healthPlugin = fastifyPlugin(
pluginDone();
},
- { fastify: '4', name: HEALTH_PLUGIN_ID },
+ { fastify: '5', name: HEALTH_PLUGIN_ID },
);
diff --git a/server/src/plugins/http-logger.ts b/server/src/plugins/http-logger.ts
index 2972d4dde..ba8198655 100644
--- a/server/src/plugins/http-logger.ts
+++ b/server/src/plugins/http-logger.ts
@@ -40,7 +40,7 @@ export const httpLoggerPlugin = fastifyPlugin(
pluginDone();
},
{
- fastify: '4',
+ fastify: '5',
name: HTTP_LOGGER_PLUGIN_ID,
dependencies: [PROXY_VERSION_PLUGIN_ID, SERVE_INDEX_PLUGIN_ID, SERVE_ASSETS_PLUGIN_ID, TAB_ID_PLUGIN_ID],
},
diff --git a/server/src/plugins/nav-ident.ts b/server/src/plugins/nav-ident.ts
index 900fcee04..ba2728c21 100644
--- a/server/src/plugins/nav-ident.ts
+++ b/server/src/plugins/nav-ident.ts
@@ -1,3 +1,4 @@
+import { parseTokenPayload } from '@app/helpers/token-parser';
import { getLogger } from '@app/logger';
import { ACCESS_TOKEN_PLUGIN_ID } from '@app/plugins/access-token';
import { CLIENT_VERSION_PLUGIN_ID } from '@app/plugins/client-version';
@@ -27,15 +28,14 @@ export const navIdentPlugin = fastifyPlugin(
return;
}
- const payload = accessToken.split('.').at(1);
+ try {
+ const parsedPayload = parseTokenPayload(accessToken);
- if (payload === undefined) {
- return;
- }
+ if (parsedPayload === undefined) {
+ return;
+ }
- try {
- const decodedPayload = Buffer.from(payload, 'base64').toString('utf-8');
- const { NAVident: navIdent } = JSON.parse(decodedPayload) as TokenPayload;
+ const { NAVident: navIdent } = parsedPayload;
if (typeof navIdent !== 'string') {
throw new Error('NAV-ident is not a string');
@@ -61,31 +61,8 @@ export const navIdentPlugin = fastifyPlugin(
pluginDone();
},
{
- fastify: '4',
+ fastify: '5',
name: NAV_IDENT_PLUGIN_ID,
dependencies: [ACCESS_TOKEN_PLUGIN_ID, CLIENT_VERSION_PLUGIN_ID, TAB_ID_PLUGIN_ID, TRACEPARENT_PLUGIN_ID],
},
);
-
-interface TokenPayload {
- aud: string;
- iss: string;
- iat: number;
- nbf: number;
- exp: number;
- aio: string;
- azp: string;
- azpacr: string;
- groups: string[];
- name: string;
- oid: string;
- preferred_username: string;
- rh: string;
- scp: string;
- sub: string;
- tid: string;
- uti: string;
- ver: string;
- NAVident: string;
- azp_name: string;
-}
diff --git a/server/src/plugins/obo-token.ts b/server/src/plugins/obo-token.ts
index c2ba2642f..94b5af6b3 100644
--- a/server/src/plugins/obo-token.ts
+++ b/server/src/plugins/obo-token.ts
@@ -9,80 +9,84 @@ import { oboRequestDuration } from '@app/auth/cache/cache-gauge';
import { ACCESS_TOKEN_PLUGIN_ID } from '@app/plugins/access-token';
import { SERVER_TIMING_PLUGIN_ID } from '@app/plugins/server-timing';
import { NAV_IDENT_PLUGIN_ID } from '@app/plugins/nav-ident';
+import { getCacheKey, oboCache } from '@app/auth/cache/cache';
const log = getLogger('obo-token-plugin');
-const oboAccessTokenMapKey = Symbol('oboAccessTokenMap');
-
declare module 'fastify' {
interface FastifyRequest {
- [oboAccessTokenMapKey]: Map;
- ensureOboAccessToken(appName: string, reply: FastifyReply): Promise;
- getOboAccessToken(appName: string): string | undefined;
+ /** Sync access to existing OBO tokens. */
+ oboAccessTokenMap: Map;
+ /** Gets OBO token and stores it in the map for syn access later. */
+ getOboAccessToken(appName: string, reply?: FastifyReply): Promise;
+ getCachedOboAccessToken(appName: string): string | undefined;
}
}
-const NOOP = async () => undefined;
+const ASYNC_NOOP = async () => undefined;
+const SYNC_NOOP = () => undefined;
export const OBO_ACCESS_TOKEN_PLUGIN_ID = 'obo-access-token';
export const oboAccessTokenPlugin = fastifyPlugin(
(app, _, pluginDone) => {
- app.decorateRequest(oboAccessTokenMapKey, null);
+ app.decorateRequest('oboAccessTokenMap');
app.addHook('onRequest', async (req): Promise => {
- req[oboAccessTokenMapKey] = new Map();
+ req.oboAccessTokenMap = new Map();
});
if (isDeployed) {
- app.decorateRequest('ensureOboAccessToken', async function (appName: string, reply: FastifyReply) {
+ app.decorateRequest('getOboAccessToken', async function (appName: string, reply?: FastifyReply) {
+ const requestOboAccessToken = this.oboAccessTokenMap.get(appName);
+
+ if (requestOboAccessToken !== undefined) {
+ return requestOboAccessToken;
+ }
+
const oboAccessToken = await getOboToken(appName, this, reply);
if (oboAccessToken !== undefined) {
- log.debug({
- msg: `Adding OBO token for "${appName}". Had ${this[oboAccessTokenMapKey].size} before.`,
- trace_id: this.trace_id,
- span_id: this.span_id,
- tab_id: this.tab_id,
- client_version: this.client_version,
- data: { route: this.url },
- });
-
- this[oboAccessTokenMapKey].set(appName, oboAccessToken);
+ this.oboAccessTokenMap.set(appName, oboAccessToken);
+ } else {
+ this.oboAccessTokenMap.delete(appName);
}
return oboAccessToken;
});
- } else {
- app.decorateRequest('ensureOboAccessToken', NOOP);
- }
- app.decorateRequest('getOboAccessToken', function (appName: string) {
- log.debug({
- msg: `Getting OBO token for "${appName}". Has ${this[oboAccessTokenMapKey].size} tokens.`,
- trace_id: this.trace_id,
- span_id: this.span_id,
- tab_id: this.tab_id,
- client_version: this.client_version,
- data: { route: this.url },
+ app.decorateRequest('getCachedOboAccessToken', function (appName: string) {
+ return (
+ this.oboAccessTokenMap.get(appName) ?? oboCache.getCached(getCacheKey(this.navIdent, appName)) ?? undefined
+ );
});
-
- return this[oboAccessTokenMapKey].get(appName);
- });
+ } else {
+ app.decorateRequest('getOboAccessToken', ASYNC_NOOP);
+ app.decorateRequest('getCachedOboAccessToken', SYNC_NOOP);
+ }
pluginDone();
},
{
- fastify: '4',
+ fastify: '5',
name: OBO_ACCESS_TOKEN_PLUGIN_ID,
dependencies: [ACCESS_TOKEN_PLUGIN_ID, NAV_IDENT_PLUGIN_ID, SERVER_TIMING_PLUGIN_ID],
},
);
-type GetOboToken = (appName: string, req: FastifyRequest, reply: FastifyReply) => Promise;
+type GetOboToken = (appName: string, req: FastifyRequest, reply?: FastifyReply) => Promise;
const getOboToken: GetOboToken = async (appName, req, reply) => {
- const { trace_id, span_id, accessToken } = req;
+ const { trace_id, span_id, accessToken, navIdent, url, client_version, tab_id } = req;
+
+ log.debug({
+ msg: `Getting OBO token for "${appName}".`,
+ trace_id,
+ span_id,
+ tab_id,
+ client_version,
+ data: { route: url },
+ });
if (accessToken.length === 0) {
return undefined;
@@ -91,19 +95,26 @@ const getOboToken: GetOboToken = async (appName, req, reply) => {
try {
const azureClientStart = performance.now();
const authClient = await getAzureADClient();
- reply.addServerTiming('azure_client_middleware', getDuration(azureClientStart), 'Azure Client Middleware');
+ reply?.addServerTiming('azure_client_middleware', getDuration(azureClientStart), 'Azure Client Middleware');
const oboStart = performance.now();
- const oboAccessToken = await getOnBehalfOfAccessToken(authClient, accessToken, appName, trace_id, span_id);
+ const oboAccessToken = await getOnBehalfOfAccessToken(
+ authClient,
+ accessToken,
+ navIdent,
+ appName,
+ trace_id,
+ span_id,
+ );
const duration = getDuration(oboStart);
oboRequestDuration.observe(duration);
- reply.addServerTiming('obo_token_middleware', duration, 'OBO Token Middleware');
+ reply?.addServerTiming('obo_token_middleware', duration, 'OBO Token Middleware');
return oboAccessToken;
} catch (error) {
log.warn({
- msg: `Failed to prepare request with OBO token.`,
+ msg: 'Failed to prepare request with OBO token.',
error,
trace_id,
span_id,
diff --git a/server/src/plugins/proxy-version.ts b/server/src/plugins/proxy-version.ts
index 757292a55..2f0c010a7 100644
--- a/server/src/plugins/proxy-version.ts
+++ b/server/src/plugins/proxy-version.ts
@@ -13,5 +13,5 @@ export const proxyVersionPlugin = fastifyPlugin(
pluginDone();
},
- { fastify: '4', name: PROXY_VERSION_PLUGIN_ID },
+ { fastify: '5', name: PROXY_VERSION_PLUGIN_ID },
);
diff --git a/server/src/plugins/serve-assets.ts b/server/src/plugins/serve-assets.ts
index 821869b03..f77f0a57c 100644
--- a/server/src/plugins/serve-assets.ts
+++ b/server/src/plugins/serve-assets.ts
@@ -55,5 +55,5 @@ export const serveAssetsPlugin = fastifyPlugin(
pluginDone();
},
- { fastify: '4', name: SERVE_ASSETS_PLUGIN_ID },
+ { fastify: '5', name: SERVE_ASSETS_PLUGIN_ID },
);
diff --git a/server/src/plugins/serve-index.ts b/server/src/plugins/serve-index.ts
index fec9376e5..5d0700740 100644
--- a/server/src/plugins/serve-index.ts
+++ b/server/src/plugins/serve-index.ts
@@ -33,5 +33,5 @@ export const serveIndexPlugin = fastifyPlugin(
pluginDone();
},
- { fastify: '4', name: SERVE_INDEX_PLUGIN_ID },
+ { fastify: '5', name: SERVE_INDEX_PLUGIN_ID },
);
diff --git a/server/src/plugins/server-timing.ts b/server/src/plugins/server-timing.ts
index d6493cf03..ebd1ddd07 100644
--- a/server/src/plugins/server-timing.ts
+++ b/server/src/plugins/server-timing.ts
@@ -171,7 +171,7 @@ export const serverTimingPlugin = fastifyPlugin(
pluginDone();
},
- { fastify: '4', name: SERVER_TIMING_PLUGIN_ID },
+ { fastify: '5', name: SERVER_TIMING_PLUGIN_ID },
);
const serverTimingsToHeaderEntries = (serverTimings: ServerTiming[]): string[] =>
diff --git a/server/src/plugins/tab-id.ts b/server/src/plugins/tab-id.ts
index 22ed4634b..6b3d3d391 100644
--- a/server/src/plugins/tab-id.ts
+++ b/server/src/plugins/tab-id.ts
@@ -25,5 +25,5 @@ export const tabIdPlugin = fastifyPlugin(
pluginDone();
},
- { fastify: '4', name: TAB_ID_PLUGIN_ID },
+ { fastify: '5', name: TAB_ID_PLUGIN_ID },
);
diff --git a/server/src/plugins/traceparent/traceparent.ts b/server/src/plugins/traceparent/traceparent.ts
index 22bcc75bc..ddaa02c62 100644
--- a/server/src/plugins/traceparent/traceparent.ts
+++ b/server/src/plugins/traceparent/traceparent.ts
@@ -32,7 +32,7 @@ export const traceparentPlugin = fastifyPlugin(
pluginDone();
},
- { fastify: '4', name: TRACEPARENT_PLUGIN_ID },
+ { fastify: '5', name: TRACEPARENT_PLUGIN_ID },
);
const TRACEPARENT_HEADER = 'traceparent';
diff --git a/server/src/plugins/version/update-request.ts b/server/src/plugins/version/update-request.ts
index 47e5d0531..6d6d95155 100644
--- a/server/src/plugins/version/update-request.ts
+++ b/server/src/plugins/version/update-request.ts
@@ -7,8 +7,8 @@ const log = getLogger('update-request');
/** Threshold for when client is required to update.
* @format `YYYY-mm-ddTHH:MM:ss`
*/
-const UPDATE_REQUIRED_THRESHOLD: `${string}-${string}-${string}T${string}:${string}:${string}` = '2024-06-10T13:37:00';
-const UPDATE_OPTIONAL_THRESHOLD: `${string}-${string}-${string}T${string}:${string}:${string}` = '2024-09-05T11:15:00';
+const UPDATE_REQUIRED_THRESHOLD: `${string}-${string}-${string}T${string}:${string}:${string}` = '2024-09-19T13:15:00';
+const UPDATE_OPTIONAL_THRESHOLD: `${string}-${string}-${string}T${string}:${string}:${string}` = '2024-09-19T13:15:00';
if (UPDATE_REQUIRED_THRESHOLD > PROXY_VERSION) {
log.error({
diff --git a/server/src/plugins/version/version.ts b/server/src/plugins/version/version.ts
index 67d41a2c4..7bce450df 100644
--- a/server/src/plugins/version/version.ts
+++ b/server/src/plugins/version/version.ts
@@ -81,7 +81,7 @@ export const versionPlugin = fastifyPlugin(
pluginDone();
},
- { fastify: '4', name: 'version' },
+ { fastify: '5', name: 'version' },
);
enum EventNames {
diff --git a/server/src/process-errors.ts b/server/src/process-errors.ts
index 7294b6481..c7c4cc234 100644
--- a/server/src/process-errors.ts
+++ b/server/src/process-errors.ts
@@ -11,6 +11,7 @@ export const processErrors = () => {
log.error({ error: reason, msg: `Process ${process.pid} received a unhandledRejection signal` });
promise.catch((error: unknown) => log.error({ error, msg: `Uncaught error` }));
+ console.error('Unhandled Rejection at:', promise, 'reason:', reason);
})
.on('uncaughtException', (error) =>
log.error({ error, msg: `Process ${process.pid} received a uncaughtException signal` }),
diff --git a/server/src/server.ts b/server/src/server.ts
index 57d3cf51d..f1831032d 100644
--- a/server/src/server.ts
+++ b/server/src/server.ts
@@ -25,6 +25,7 @@ import { httpLoggerPlugin } from '@app/plugins/http-logger';
import { proxyVersionPlugin } from '@app/plugins/proxy-version';
import { healthPlugin } from '@app/plugins/health';
import { navIdentPlugin } from '@app/plugins/nav-ident';
+import { crdtPlugin } from '@app/plugins/crdt/crdt';
processErrors();
@@ -61,6 +62,7 @@ fastify({ trustProxy: true, querystringParser, bodyLimit })
.register(serveAssetsPlugin)
.register(serveIndexPlugin)
.register(httpLoggerPlugin)
+ .register(crdtPlugin)
// Start server.
.listen({ host: '0.0.0.0', port: serverConfig.port });