@mereb/web-runtime 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ type ShellRuntimeEnv = {
2
+ REMOTE_MANIFEST_URL?: string;
3
+ GRAPHQL_URL?: string;
4
+ FLAGS_URL?: string;
5
+ KEYCLOAK_URL?: string;
6
+ KC_REALM?: string;
7
+ KC_CLIENT_ID?: string;
8
+ };
9
+ declare global {
10
+ interface Window {
11
+ __MEREB_ENV__?: Partial<ShellRuntimeEnv>;
12
+ }
13
+ }
14
+ export declare const appConfig: {
15
+ remoteManifestUrl: string;
16
+ graphqlUrl: string;
17
+ flagsUrl: string;
18
+ keycloak: {
19
+ url: string;
20
+ realm: string;
21
+ clientId: string;
22
+ };
23
+ };
24
+ export type AppConfig = typeof appConfig;
25
+ export declare function resolveKeycloakMissingKeys(): readonly (keyof typeof appConfig.keycloak)[];
26
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,34 @@
1
+ const defaultRuntime = {
2
+ REMOTE_MANIFEST_URL: '',
3
+ GRAPHQL_URL: 'http://localhost:8000/graphql',
4
+ FLAGS_URL: 'http://localhost:8000/flags',
5
+ KEYCLOAK_URL: 'http://localhost:8081',
6
+ KC_REALM: 'social',
7
+ KC_CLIENT_ID: 'web-shell'
8
+ };
9
+ const runtimeEnv = globalThis.window === undefined ? {} : globalThis.window.__MEREB_ENV__ ?? {};
10
+ function resolveEnv(metaValue, key) {
11
+ const trimmedMeta = metaValue?.trim();
12
+ if (trimmedMeta) {
13
+ return trimmedMeta;
14
+ }
15
+ const runtimeValue = runtimeEnv[key]?.trim();
16
+ if (runtimeValue) {
17
+ return runtimeValue;
18
+ }
19
+ return defaultRuntime[key] ?? '';
20
+ }
21
+ export const appConfig = {
22
+ remoteManifestUrl: resolveEnv(import.meta.env.VITE_REMOTE_MANIFEST_URL, 'REMOTE_MANIFEST_URL'),
23
+ graphqlUrl: resolveEnv(import.meta.env.VITE_GRAPHQL_URL, 'GRAPHQL_URL'),
24
+ flagsUrl: resolveEnv(import.meta.env.VITE_FLAGS_URL, 'FLAGS_URL'),
25
+ keycloak: {
26
+ url: resolveEnv(import.meta.env.VITE_KEYCLOAK_URL, 'KEYCLOAK_URL'),
27
+ realm: resolveEnv(import.meta.env.VITE_KC_REALM, 'KC_REALM'),
28
+ clientId: resolveEnv(import.meta.env.VITE_KC_CLIENT_ID, 'KC_CLIENT_ID')
29
+ }
30
+ };
31
+ export function resolveKeycloakMissingKeys() {
32
+ const keys = ['url', 'realm', 'clientId'];
33
+ return keys.filter((key) => !appConfig.keycloak[key]);
34
+ }
@@ -0,0 +1,4 @@
1
+ export * from './config.js';
2
+ export * from './providers/AppProviders.js';
3
+ export * from './providers/auth.js';
4
+ export * from './providers/flags.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from './config.js';
2
+ export * from './providers/AppProviders.js';
3
+ export * from './providers/auth.js';
4
+ export * from './providers/flags.js';
@@ -0,0 +1,2 @@
1
+ import type { PropsWithChildren } from 'react';
2
+ export declare function AppProviders({ children }: Readonly<PropsWithChildren>): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useMemo } from 'react';
3
+ import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client';
4
+ import { ApolloProvider } from '@apollo/client/react';
5
+ import { AuthProvider, useAuthToken } from './auth.js';
6
+ import { FlagsProvider } from './flags.js';
7
+ import { appConfig } from '../config.js';
8
+ function GraphProvider({ children }) {
9
+ const token = useAuthToken();
10
+ const client = useMemo(() => new ApolloClient({
11
+ link: new HttpLink({
12
+ uri: appConfig.graphqlUrl,
13
+ headers: {
14
+ Accept: 'application/json',
15
+ 'Content-Type': 'application/json',
16
+ ...(token ? { Authorization: `Bearer ${token}` } : {})
17
+ }
18
+ }),
19
+ cache: new InMemoryCache()
20
+ }), [token]);
21
+ return _jsx(ApolloProvider, { client: client, children: children });
22
+ }
23
+ export function AppProviders({ children }) {
24
+ return (_jsx(AuthProvider, { children: _jsx(FlagsProvider, { children: _jsx(GraphProvider, { children: children }) }) }));
25
+ }
@@ -0,0 +1,30 @@
1
+ import type { KeycloakLoginOptions, KeycloakRegisterOptions } from 'keycloak-js';
2
+ import type { ReactNode } from 'react';
3
+ type AdminAccessLevel = 'full' | 'limited' | 'none';
4
+ export type AuthProfile = {
5
+ id: string;
6
+ username?: string;
7
+ name?: string;
8
+ email?: string;
9
+ roles: string[];
10
+ adminAccess: AdminAccessLevel;
11
+ };
12
+ type AuthContextValue = {
13
+ isReady: boolean;
14
+ isAuthConfigured: boolean;
15
+ isAuthenticated: boolean;
16
+ token?: string;
17
+ profile?: AuthProfile;
18
+ login: (options?: KeycloakLoginOptions) => void;
19
+ register: (options?: KeycloakRegisterOptions) => void;
20
+ logout: () => void;
21
+ hasRole: (role: string) => boolean;
22
+ hasAnyRole: (roles: string[]) => boolean;
23
+ missingConfigKeys: readonly string[];
24
+ };
25
+ export declare function AuthProvider({ children }: Readonly<{
26
+ children: ReactNode;
27
+ }>): import("react/jsx-runtime").JSX.Element;
28
+ export declare const useAuth: () => AuthContextValue;
29
+ export declare const useAuthToken: () => string | undefined;
30
+ export {};
@@ -0,0 +1,271 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
3
+ import Keycloak from 'keycloak-js';
4
+ import { appConfig, resolveKeycloakMissingKeys } from '../config.js';
5
+ const AuthCtx = createContext({
6
+ isReady: false,
7
+ isAuthConfigured: false,
8
+ isAuthenticated: false,
9
+ token: undefined,
10
+ profile: undefined,
11
+ login: () => { },
12
+ register: () => { },
13
+ logout: () => { },
14
+ hasRole: () => false,
15
+ hasAnyRole: () => false,
16
+ missingConfigKeys: []
17
+ });
18
+ const keycloakConfig = {
19
+ url: appConfig.keycloak.url,
20
+ realm: appConfig.keycloak.realm,
21
+ clientId: appConfig.keycloak.clientId
22
+ };
23
+ const missingConfigKeys = resolveKeycloakMissingKeys();
24
+ const isKeycloakConfigured = missingConfigKeys.length === 0;
25
+ let keycloakInstance = null;
26
+ let keycloakInitPromise = null;
27
+ const FULL_ADMIN_ROLES = new Set(['admin', 'mereb.admin', 'realm-admin']);
28
+ const LIMITED_ADMIN_ROLES = new Set(['moderator', 'support', 'admin.viewer', 'mereb.staff']);
29
+ function extractRoles(token) {
30
+ if (!token)
31
+ return [];
32
+ const realmRoles = token.realm_access?.roles ?? [];
33
+ const resourceRoles = Object.values(token.resource_access ?? {}).flatMap((access) => access.roles ?? []);
34
+ return Array.from(new Set([...realmRoles, ...resourceRoles]));
35
+ }
36
+ function determineAdminAccess(roles) {
37
+ if (roles.some((role) => FULL_ADMIN_ROLES.has(role)))
38
+ return 'full';
39
+ if (roles.some((role) => LIMITED_ADMIN_ROLES.has(role)))
40
+ return 'limited';
41
+ return 'none';
42
+ }
43
+ function buildProfile(instance) {
44
+ if (!instance.authenticated || !instance.tokenParsed)
45
+ return undefined;
46
+ const parsed = instance.tokenParsed;
47
+ const roles = extractRoles(parsed);
48
+ return {
49
+ id: parsed?.sub ?? '',
50
+ username: parsed?.preferred_username,
51
+ name: parsed?.name,
52
+ email: parsed?.email,
53
+ roles,
54
+ adminAccess: determineAdminAccess(roles)
55
+ };
56
+ }
57
+ function getKeycloak() {
58
+ if (!isKeycloakConfigured) {
59
+ throw new Error(`Keycloak configuration is incomplete (missing: ${missingConfigKeys.join(', ')})`);
60
+ }
61
+ keycloakInstance ??= new Keycloak(keycloakConfig);
62
+ return keycloakInstance;
63
+ }
64
+ function extractErrorMessage(error) {
65
+ if (error instanceof Error) {
66
+ return error.message ?? '';
67
+ }
68
+ if (typeof error === 'string') {
69
+ return error;
70
+ }
71
+ if (error && typeof error === 'object') {
72
+ const errObj = error;
73
+ let maybeMessage;
74
+ if ('error_description' in errObj) {
75
+ maybeMessage = errObj.error_description;
76
+ }
77
+ else if ('error' in errObj) {
78
+ maybeMessage = errObj.error;
79
+ }
80
+ if (typeof maybeMessage === 'string') {
81
+ return maybeMessage;
82
+ }
83
+ try {
84
+ return JSON.stringify(error);
85
+ }
86
+ catch {
87
+ return '';
88
+ }
89
+ }
90
+ return '';
91
+ }
92
+ function shouldRetryWithoutPkce(error) {
93
+ const message = extractErrorMessage(error).toLowerCase();
94
+ if (!message)
95
+ return false;
96
+ return message.includes('pkce') || message.includes('code_challenge');
97
+ }
98
+ function shouldDisableSilentSso(error) {
99
+ const message = extractErrorMessage(error).toLowerCase();
100
+ if (!message)
101
+ return false;
102
+ return message.includes('3rd party check iframe');
103
+ }
104
+ function resetPkceState(instance) {
105
+ ;
106
+ instance.pkceMethod = undefined;
107
+ }
108
+ function disableSilentSso(instance) {
109
+ const mutableInstance = instance;
110
+ mutableInstance.silentCheckSsoRedirectUri = false;
111
+ mutableInstance.silentCheckSsoFallback = false;
112
+ }
113
+ async function attemptKeycloakInit(instance, options) {
114
+ try {
115
+ return await instance.init(options);
116
+ }
117
+ catch (error) {
118
+ if (options.pkceMethod && shouldRetryWithoutPkce(error)) {
119
+ console.warn('[auth] Keycloak PKCE handshake failed. Retrying without PKCE. Enable PKCE (S256) on the client to avoid this fallback.', error);
120
+ const fallbackOptions = { ...options };
121
+ delete fallbackOptions.pkceMethod;
122
+ resetPkceState(instance);
123
+ return attemptKeycloakInit(instance, fallbackOptions);
124
+ }
125
+ if (options.silentCheckSsoRedirectUri && shouldDisableSilentSso(error)) {
126
+ console.warn('[auth] Silent SSO cookie probe failed (likely blocked /protocol/openid-connect/3p-cookies). Retrying without silent SSO so the shell can still load.', error);
127
+ const fallbackOptions = { ...options };
128
+ delete fallbackOptions.silentCheckSsoRedirectUri;
129
+ fallbackOptions.silentCheckSsoFallback = false;
130
+ disableSilentSso(instance);
131
+ return attemptKeycloakInit(instance, fallbackOptions);
132
+ }
133
+ throw error;
134
+ }
135
+ }
136
+ function initKeycloak() {
137
+ if (!isKeycloakConfigured)
138
+ return Promise.resolve(false);
139
+ if (!keycloakInitPromise) {
140
+ const instance = getKeycloak();
141
+ const options = {
142
+ onLoad: 'check-sso',
143
+ pkceMethod: 'S256',
144
+ silentCheckSsoRedirectUri: `${globalThis.location.origin}/silent-check-sso.html`,
145
+ checkLoginIframe: false,
146
+ useNonce: false
147
+ };
148
+ keycloakInitPromise = attemptKeycloakInit(instance, options).catch((error) => {
149
+ keycloakInitPromise = null;
150
+ throw error ?? new Error('Keycloak init failed');
151
+ });
152
+ }
153
+ return keycloakInitPromise;
154
+ }
155
+ export function AuthProvider({ children }) {
156
+ const [isReady, setIsReady] = useState(!isKeycloakConfigured);
157
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
158
+ const [token, setToken] = useState();
159
+ const [profile, setProfile] = useState();
160
+ useEffect(() => {
161
+ if (!isKeycloakConfigured) {
162
+ console.warn('[auth] Keycloak is disabled because the following env vars are missing:', missingConfigKeys.join(', '));
163
+ setIsReady(true);
164
+ return;
165
+ }
166
+ let isCancelled = false;
167
+ const instance = getKeycloak();
168
+ const syncFromInstance = () => {
169
+ if (isCancelled)
170
+ return;
171
+ setIsAuthenticated(Boolean(instance.authenticated));
172
+ setToken(instance.token ?? undefined);
173
+ setProfile(buildProfile(instance));
174
+ };
175
+ initKeycloak()
176
+ .then((authenticated) => {
177
+ if (isCancelled)
178
+ return;
179
+ if (authenticated) {
180
+ syncFromInstance();
181
+ }
182
+ else {
183
+ setIsAuthenticated(false);
184
+ setToken(undefined);
185
+ setProfile(undefined);
186
+ }
187
+ setIsReady(true);
188
+ })
189
+ .catch((err) => {
190
+ console.error('Keycloak failed to initialise', err ?? new Error('Unknown Keycloak init failure'));
191
+ if (!isCancelled) {
192
+ setIsReady(true);
193
+ }
194
+ });
195
+ instance.onTokenExpired = () => {
196
+ void instance.updateToken(0)
197
+ .then(() => syncFromInstance())
198
+ .catch((err) => {
199
+ console.error('Failed to refresh Keycloak token', err);
200
+ });
201
+ };
202
+ instance.onAuthSuccess = syncFromInstance;
203
+ instance.onAuthRefreshSuccess = syncFromInstance;
204
+ instance.onAuthLogout = () => {
205
+ if (isCancelled)
206
+ return;
207
+ setIsAuthenticated(false);
208
+ setToken(undefined);
209
+ setProfile(undefined);
210
+ };
211
+ const refreshId = globalThis.setInterval(async () => {
212
+ if (!instance.authenticated)
213
+ return;
214
+ const refreshed = await instance.updateToken(30).catch(() => false);
215
+ if (refreshed)
216
+ syncFromInstance();
217
+ }, 20_000);
218
+ return () => {
219
+ isCancelled = true;
220
+ globalThis.clearInterval(refreshId);
221
+ instance.onTokenExpired = () => { };
222
+ instance.onAuthSuccess = () => { };
223
+ instance.onAuthRefreshSuccess = () => { };
224
+ instance.onAuthLogout = () => { };
225
+ };
226
+ }, []);
227
+ const login = useCallback((options) => {
228
+ if (!isKeycloakConfigured) {
229
+ console.warn('[auth] Keycloak login skipped: configuration is missing.', missingConfigKeys);
230
+ return;
231
+ }
232
+ getKeycloak().login(options).catch((err) => {
233
+ console.error('Keycloak login failed', err);
234
+ });
235
+ }, []);
236
+ const register = useCallback((options) => {
237
+ if (!isKeycloakConfigured) {
238
+ console.warn('[auth] Keycloak register skipped: configuration is missing.', missingConfigKeys);
239
+ return;
240
+ }
241
+ getKeycloak().register(options).catch((err) => {
242
+ console.error('Keycloak registration failed', err);
243
+ });
244
+ }, []);
245
+ const logout = useCallback(() => {
246
+ if (!isKeycloakConfigured) {
247
+ console.warn('[auth] Keycloak logout skipped: configuration is missing.', missingConfigKeys);
248
+ return;
249
+ }
250
+ getKeycloak().logout().catch((err) => {
251
+ console.error('Keycloak logout failed', err);
252
+ });
253
+ }, []);
254
+ const roleSet = useMemo(() => new Set(profile?.roles ?? []), [profile]);
255
+ const value = useMemo(() => ({
256
+ isReady,
257
+ isAuthConfigured: isKeycloakConfigured,
258
+ isAuthenticated,
259
+ token,
260
+ profile,
261
+ login,
262
+ register,
263
+ logout,
264
+ hasRole: (role) => roleSet.has(role),
265
+ hasAnyRole: (roles) => roles.some((role) => roleSet.has(role)),
266
+ missingConfigKeys
267
+ }), [isReady, isAuthenticated, token, profile, login, register, logout, roleSet]);
268
+ return _jsx(AuthCtx.Provider, { value: value, children: children });
269
+ }
270
+ export const useAuth = () => useContext(AuthCtx);
271
+ export const useAuthToken = () => useAuth().token;
@@ -0,0 +1,13 @@
1
+ import type { ReactNode } from 'react';
2
+ export type Flags = Record<string, boolean>;
3
+ type FlagsContextValue = {
4
+ flags: Flags;
5
+ loading: boolean;
6
+ error?: string;
7
+ };
8
+ export declare function FlagsProvider({ children }: Readonly<{
9
+ children: ReactNode;
10
+ }>): import("react/jsx-runtime").JSX.Element;
11
+ export declare function useFlags(): FlagsContextValue;
12
+ export declare function useFlag(name: string): boolean;
13
+ export {};
@@ -0,0 +1,58 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useEffect, useMemo, useState } from 'react';
3
+ import { appConfig } from '../config.js';
4
+ const FlagsCtx = createContext({
5
+ flags: {},
6
+ loading: false,
7
+ error: undefined
8
+ });
9
+ export function FlagsProvider({ children }) {
10
+ const [flags, setFlags] = useState({});
11
+ const [loading, setLoading] = useState(false);
12
+ const [error, setError] = useState();
13
+ useEffect(() => {
14
+ if (!appConfig.flagsUrl) {
15
+ setFlags({});
16
+ setLoading(false);
17
+ setError(undefined);
18
+ return;
19
+ }
20
+ const controller = new AbortController();
21
+ setLoading(true);
22
+ setError(undefined);
23
+ fetch(appConfig.flagsUrl, { credentials: 'include', signal: controller.signal })
24
+ .then(async (response) => {
25
+ if (!response.ok) {
26
+ throw new Error(`Flags request failed (${response.status})`);
27
+ }
28
+ return response.json();
29
+ })
30
+ .then((nextFlags) => {
31
+ setFlags(nextFlags);
32
+ })
33
+ .catch((err) => {
34
+ if (controller.signal.aborted)
35
+ return;
36
+ setFlags({});
37
+ setError(err instanceof Error ? err.message : 'Unknown error');
38
+ })
39
+ .finally(() => {
40
+ if (!controller.signal.aborted) {
41
+ setLoading(false);
42
+ }
43
+ });
44
+ return () => controller.abort();
45
+ }, []);
46
+ const value = useMemo(() => ({
47
+ flags,
48
+ loading,
49
+ error
50
+ }), [flags, loading, error]);
51
+ return _jsx(FlagsCtx.Provider, { value: value, children: children });
52
+ }
53
+ export function useFlags() {
54
+ return useContext(FlagsCtx);
55
+ }
56
+ export function useFlag(name) {
57
+ return useFlags().flags[name];
58
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@mereb/web-runtime",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist",
9
+ "package.json"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./config": {
18
+ "types": "./dist/config.d.ts",
19
+ "import": "./dist/config.js",
20
+ "default": "./dist/config.js"
21
+ },
22
+ "./auth": {
23
+ "types": "./dist/providers/auth.d.ts",
24
+ "import": "./dist/providers/auth.js",
25
+ "default": "./dist/providers/auth.js"
26
+ },
27
+ "./flags": {
28
+ "types": "./dist/providers/flags.d.ts",
29
+ "import": "./dist/providers/flags.js",
30
+ "default": "./dist/providers/flags.js"
31
+ },
32
+ "./AppProviders": {
33
+ "types": "./dist/providers/AppProviders.d.ts",
34
+ "import": "./dist/providers/AppProviders.js",
35
+ "default": "./dist/providers/AppProviders.js"
36
+ }
37
+ },
38
+ "dependencies": {
39
+ "@apollo/client": "^4.0.7",
40
+ "keycloak-js": "^23.0.7"
41
+ },
42
+ "peerDependencies": {
43
+ "graphql": "^16.0.0",
44
+ "react": ">=18.2.0 <19",
45
+ "react-dom": ">=18.2.0 <19"
46
+ },
47
+ "devDependencies": {
48
+ "@testing-library/jest-dom": "^6.6.3",
49
+ "@testing-library/react": "^16.3.0",
50
+ "@types/react": "^18.2.79",
51
+ "husky": "^9.1.7",
52
+ "jsdom": "^26.1.0",
53
+ "react": "18.2.0",
54
+ "react-dom": "18.2.0",
55
+ "typescript": "~5.9.3",
56
+ "vitest": "^3.2.4"
57
+ },
58
+ "scripts": {
59
+ "clean": "node -e \"const fs=require('node:fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('tsconfig.tsbuildinfo',{force:true});\"",
60
+ "typecheck": "tsc --noEmit --project tsconfig.json",
61
+ "test": "pnpm exec vitest run --config vitest.config.ts",
62
+ "test:watch": "pnpm exec vitest --config vitest.config.ts",
63
+ "build": "pnpm run clean && tsc --project tsconfig.json",
64
+ "version:bump": "node ./scripts/bump-version.mjs"
65
+ }
66
+ }