@iblai/data-layer 1.12.107 → 1.13.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.
package/dist/index.js CHANGED
@@ -39144,6 +39144,12 @@ Config.axdUrl = 'https://base.manager.iblai.app';
39144
39144
  Config.mentorIframeUrl = 'https://mentor.iblai.tech';
39145
39145
  Config.studioUrl = 'https://studio.iblai.app';
39146
39146
  Config.httpErrorHandlers = {};
39147
+ /**
39148
+ * Optional override for how expired auth tokens are re-minted after a 401.
39149
+ * Resolve `true` once fresh tokens are in storage. Left unset, the data
39150
+ * layer mints them itself from the edX session.
39151
+ */
39152
+ Config.refreshAuthToken = undefined;
39147
39153
 
39148
39154
  class IblDataLayer {
39149
39155
  }
@@ -39198,6 +39204,133 @@ const iblFakeBaseQuery = react.retry(fakeBaseQueryWithLogging, {
39198
39204
  },
39199
39205
  });
39200
39206
 
39207
+ const AUTH_ENDPOINTS = {
39208
+ GET_MFE_CONTEXT: {
39209
+ service: exports.SERVICES.LMS,
39210
+ path: () => `/api/mfe_context`,
39211
+ },
39212
+ APPLE_EXCHANGE_TOKEN: {
39213
+ service: exports.SERVICES.LMS,
39214
+ path: () => `/oauth2/exchange_access_token/apple-id/`,
39215
+ },
39216
+ GET_APP_TOKENS: {
39217
+ service: exports.SERVICES.LMS,
39218
+ path: () => '/api/ibl/manager/consolidated-token/proxy/',
39219
+ },
39220
+ REFRESH_JWT_TOKEN: {
39221
+ service: exports.SERVICES.LEGACY_LMS,
39222
+ path: () => '/ibl-auth/request-jwt/',
39223
+ },
39224
+ };
39225
+ const AUTH_REDUCER_PATH = 'authApiSlice';
39226
+
39227
+ /**
39228
+ * Expiry keys are written alongside the tokens so `hasNonExpiredAuthToken()`
39229
+ * and friends don't treat a freshly minted token as stale.
39230
+ */
39231
+ const AXD_TOKEN_EXPIRES_KEY = 'axd_token_expires';
39232
+ const DM_TOKEN_EXPIRES_KEY = 'dm_token_expires';
39233
+ /** Reads the platform key the tokens should be minted for. */
39234
+ const readPlatformKey = async () => {
39235
+ var _a;
39236
+ const storage = IblDataLayer.storage;
39237
+ if (!storage)
39238
+ return null;
39239
+ const raw = await storage.getItem(STORAGE_KEYS.CURRENT_TENANT);
39240
+ if (raw) {
39241
+ try {
39242
+ const key = (_a = JSON.parse(raw)) === null || _a === void 0 ? void 0 : _a.key;
39243
+ if (key)
39244
+ return key;
39245
+ }
39246
+ catch (_b) {
39247
+ // `current_tenant` is occasionally stored as a bare slug rather than the
39248
+ // serialised tenant object; use it as-is.
39249
+ if (raw.trim())
39250
+ return raw;
39251
+ }
39252
+ }
39253
+ return (await storage.getItem(STORAGE_KEYS.TENANT)) || null;
39254
+ };
39255
+ /**
39256
+ * Mints a fresh set of app tokens from the edX session and writes them to
39257
+ * storage. Deliberately uses `fetch` rather than the data layer's own base
39258
+ * query: routing it through `iblFetchBaseQuery` would re-enter the 401
39259
+ * handling this function exists to serve.
39260
+ */
39261
+ const defaultRefreshAuthToken = async () => {
39262
+ var _a, _b;
39263
+ const storage = IblDataLayer.storage;
39264
+ if (!storage)
39265
+ return false;
39266
+ const platformKey = await readPlatformKey();
39267
+ if (!platformKey) {
39268
+ console.warn('[token-refresh] No platform key in storage, cannot refresh');
39269
+ return false;
39270
+ }
39271
+ // The consolidated-token endpoint authenticates with the edX JWT, not with
39272
+ // the axd/dm tokens that just expired.
39273
+ const authHeaders = await getHeaders(AUTH_ENDPOINTS.GET_APP_TOKENS.service);
39274
+ if (!(authHeaders === null || authHeaders === void 0 ? void 0 : authHeaders.Authorization)) {
39275
+ console.warn('[token-refresh] No edX token available, cannot refresh');
39276
+ return false;
39277
+ }
39278
+ const formData = new FormData();
39279
+ formData.append('platform_key', platformKey);
39280
+ const url = `${getServiceUrl(AUTH_ENDPOINTS.GET_APP_TOKENS.service)}${AUTH_ENDPOINTS.GET_APP_TOKENS.path()}`;
39281
+ const response = await fetch(url, {
39282
+ method: 'POST',
39283
+ headers: { Authorization: authHeaders.Authorization },
39284
+ body: formData,
39285
+ });
39286
+ if (!response.ok) {
39287
+ console.warn(`[token-refresh] Mint failed with ${response.status}`);
39288
+ return false;
39289
+ }
39290
+ const body = (await response.json());
39291
+ const axdToken = (_a = body === null || body === void 0 ? void 0 : body.data) === null || _a === void 0 ? void 0 : _a.axd_token;
39292
+ const dmToken = (_b = body === null || body === void 0 ? void 0 : body.data) === null || _b === void 0 ? void 0 : _b.dm_token;
39293
+ if (!(axdToken === null || axdToken === void 0 ? void 0 : axdToken.token) || !(dmToken === null || dmToken === void 0 ? void 0 : dmToken.token)) {
39294
+ console.warn('[token-refresh] Mint returned no tokens');
39295
+ return false;
39296
+ }
39297
+ await storage.setItem(STORAGE_KEYS.AXD_TOKEN_KEY, axdToken.token);
39298
+ await storage.setItem(STORAGE_KEYS.DM_TOKEN_KEY, dmToken.token);
39299
+ if (axdToken.expires) {
39300
+ await storage.setItem(AXD_TOKEN_EXPIRES_KEY, axdToken.expires);
39301
+ }
39302
+ if (dmToken.expires) {
39303
+ await storage.setItem(DM_TOKEN_EXPIRES_KEY, dmToken.expires);
39304
+ }
39305
+ return true;
39306
+ };
39307
+ /** The refresh currently in flight, if any. */
39308
+ let refreshInFlight = null;
39309
+ /**
39310
+ * Refreshes the auth tokens, collapsing concurrent callers onto one request.
39311
+ * A page load that fires ten requests in parallel produces ten 401s; without
39312
+ * this they would mint ten token sets and race each other into storage.
39313
+ *
39314
+ * Never rejects — a thrown refresh resolves `false`, which callers read as
39315
+ * "give up and run the registered 401 handler" (i.e. log out).
39316
+ */
39317
+ const refreshAuthTokenOnce = async () => {
39318
+ var _a;
39319
+ if (!refreshInFlight) {
39320
+ const refresh = (_a = Config.refreshAuthToken) !== null && _a !== void 0 ? _a : defaultRefreshAuthToken;
39321
+ refreshInFlight = Promise.resolve()
39322
+ .then(() => refresh())
39323
+ .catch((error) => {
39324
+ console.error('[token-refresh] Refresh threw:', error);
39325
+ return false;
39326
+ })
39327
+ .finally(() => {
39328
+ refreshInFlight = null;
39329
+ });
39330
+ }
39331
+ return refreshInFlight;
39332
+ };
39333
+
39201
39334
  /**
39202
39335
  * Get the service URL based on the provided service type.
39203
39336
  */
@@ -39292,6 +39425,7 @@ const buildEndpointFromService = (service, serviceFn, options) => {
39292
39425
  async queryFn(args) {
39293
39426
  var _a;
39294
39427
  let lastError;
39428
+ let refreshAttempted = false;
39295
39429
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
39296
39430
  try {
39297
39431
  OpenAPI.BASE = getServiceUrl(service);
@@ -39303,6 +39437,17 @@ const buildEndpointFromService = (service, serviceFn, options) => {
39303
39437
  catch (err) {
39304
39438
  lastError = err;
39305
39439
  const status = err === null || err === void 0 ? void 0 : err.status;
39440
+ // An expired token is recoverable: mint a new one and replay the
39441
+ // call (headers are rebuilt at the top of the loop) rather than
39442
+ // dropping straight to the 401 handler, which logs the user out.
39443
+ // One attempt per call, so a token the server keeps rejecting can't
39444
+ // loop.
39445
+ if (status === 401 && !refreshAttempted) {
39446
+ refreshAttempted = true;
39447
+ if (await refreshAuthTokenOnce()) {
39448
+ continue;
39449
+ }
39450
+ }
39306
39451
  // Don't retry auth errors, statuses the endpoint expects as an
39307
39452
  // empty state, or a spend-cap block (fail fast — no retry storm).
39308
39453
  if (QUERYFN_NON_RETRYABLE_STATUSES.includes(status) ||
@@ -39457,10 +39602,23 @@ const withNonRetryableErrors = (query$1) => async (args, api, extraOptions) => {
39457
39602
  const iblFetchBaseQuery = async (args, api, extraOptions) => {
39458
39603
  var _a, _b, _c;
39459
39604
  try {
39605
+ const runQuery = () => {
39606
+ var _a;
39607
+ return query.retry(withNonRetryableErrors(baseQuery(args.service, (_a = args === null || args === void 0 ? void 0 : args.isJson) !== null && _a !== void 0 ? _a : true, args === null || args === void 0 ? void 0 : args.contentType, args === null || args === void 0 ? void 0 : args.skipAuth)), {
39608
+ maxRetries: 3,
39609
+ })(args, api, extraOptions);
39610
+ };
39460
39611
  // Executing base query
39461
- const result = await query.retry(withNonRetryableErrors(baseQuery(args.service, (_a = args === null || args === void 0 ? void 0 : args.isJson) !== null && _a !== void 0 ? _a : true, args === null || args === void 0 ? void 0 : args.contentType, args === null || args === void 0 ? void 0 : args.skipAuth)), {
39462
- maxRetries: 3,
39463
- })(args, api, extraOptions);
39612
+ let result = await runQuery();
39613
+ // An expired token is recoverable: mint a new one and replay the request
39614
+ // (`prepareHeaders` re-reads storage per call) rather than dropping
39615
+ // straight to the 401 handler, which logs the user out. One attempt per
39616
+ // request, so a token the server keeps rejecting can't loop.
39617
+ if (((_a = result.error) === null || _a === void 0 ? void 0 : _a.status) === 401 && !(args === null || args === void 0 ? void 0 : args.skipAuth)) {
39618
+ if (await refreshAuthTokenOnce()) {
39619
+ result = await runQuery();
39620
+ }
39621
+ }
39464
39622
  // Query completed
39465
39623
  if (result.error) {
39466
39624
  console.log('[MONETIZATION ERROR HANDLER]: ', { result });
@@ -39534,6 +39692,7 @@ const buildEndpointFromServiceLegacy = (service, serviceFn, options) => {
39534
39692
  return {
39535
39693
  async queryFn(args) {
39536
39694
  let lastError;
39695
+ let refreshAttempted = false;
39537
39696
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
39538
39697
  try {
39539
39698
  OpenAPI.BASE = getServiceUrl(service);
@@ -39545,6 +39704,14 @@ const buildEndpointFromServiceLegacy = (service, serviceFn, options) => {
39545
39704
  catch (err) {
39546
39705
  lastError = err;
39547
39706
  const status = err === null || err === void 0 ? void 0 : err.status;
39707
+ // See the note in `buildEndpointFromService`: refresh once and
39708
+ // replay before letting a 401 reach the logout handler.
39709
+ if (status === 401 && !refreshAttempted) {
39710
+ refreshAttempted = true;
39711
+ if (await refreshAuthTokenOnce()) {
39712
+ continue;
39713
+ }
39714
+ }
39548
39715
  if (QUERYFN_NON_RETRYABLE_STATUSES.includes(status) ||
39549
39716
  expectedErrorStatuses.includes(status)) {
39550
39717
  break;
@@ -40229,26 +40396,6 @@ const promptsApiSlice = react.createApi({
40229
40396
  });
40230
40397
  const { useGetPromptCategoriesQuery, useLazyGetPromptCategoriesQuery, useGetPromptsQuery, useLazyGetPromptsQuery, useGetPromptsSearchQuery, useLazyGetPromptsSearchQuery, useCreatePromptMutation, useUpdatePromptMutation, useDeletePromptMutation, useGetGuidedPromptsQuery, useLazyGetGuidedPromptsQuery, } = promptsApiSlice;
40231
40398
 
40232
- const AUTH_ENDPOINTS = {
40233
- GET_MFE_CONTEXT: {
40234
- service: exports.SERVICES.LMS,
40235
- path: () => `/api/mfe_context`,
40236
- },
40237
- APPLE_EXCHANGE_TOKEN: {
40238
- service: exports.SERVICES.LMS,
40239
- path: () => `/oauth2/exchange_access_token/apple-id/`,
40240
- },
40241
- GET_APP_TOKENS: {
40242
- service: exports.SERVICES.LMS,
40243
- path: () => '/api/ibl/manager/consolidated-token/proxy/',
40244
- },
40245
- REFRESH_JWT_TOKEN: {
40246
- service: exports.SERVICES.LEGACY_LMS,
40247
- path: () => '/ibl-auth/request-jwt/',
40248
- },
40249
- };
40250
- const AUTH_REDUCER_PATH = 'authApiSlice';
40251
-
40252
40399
  const authApiSlice = react.createApi({
40253
40400
  reducerPath: AUTH_REDUCER_PATH,
40254
40401
  baseQuery: iblFetchBaseQuery,
@@ -40799,6 +40946,17 @@ const USERS_ENDPOINTS = {
40799
40946
  service: exports.SERVICES.LMS,
40800
40947
  path: () => `/api/ibl/users/manage/roles/`,
40801
40948
  },
40949
+ // All roles assigned to a user across every scope (global, org, course).
40950
+ // The same path is served by the CMS/Studio host and, deprecated, by the LMS.
40951
+ // Studio is the default because course-creation roles are only fully
40952
+ // resolvable there; `lmsService` is the fallback for deployments without a
40953
+ // reachable Studio host. Both hosts share one cache tag, so this stays a
40954
+ // single endpoint rather than two.
40955
+ GET_USER_ROLES: {
40956
+ service: exports.SERVICES.STUDIO,
40957
+ lmsService: exports.SERVICES.LMS,
40958
+ path: () => `/api/ibl/users/manage/roles/`,
40959
+ },
40802
40960
  UPLOAD_PROFILE_IMAGE: {
40803
40961
  service: exports.SERVICES.LMS,
40804
40962
  path: (username) => `/api/user/v1/accounts/${username}/image`,
@@ -40828,6 +40986,7 @@ const USERS_QUERY_KEYS = {
40828
40986
  GET_USER_METADATA_EDX: () => ['USER_METADATA_EDX'],
40829
40987
  RESET_PASSWORD: () => ['RESET_PASSWORD'],
40830
40988
  UPDATE_USER_ROLES: () => ['UPDATE_USER_ROLES'],
40989
+ GET_USER_ROLES: () => ['USER_ROLES'],
40831
40990
  UPLOAD_PROFILE_IMAGE: () => ['UPLOAD_PROFILE_IMAGE'],
40832
40991
  EDX_UPDATE_USER_METADATA: () => ['EDX_UPDATE_USER_METADATA'],
40833
40992
  EDX_GET_USER_METADATA: () => ['EDX_GET_USER_METADATA'],
@@ -40842,6 +41001,7 @@ const userApiSlice = react.createApi({
40842
41001
  ...USERS_QUERY_KEYS.GET_USER_METADATA(),
40843
41002
  ...USERS_QUERY_KEYS.GET_USER_METADATA_EDX(),
40844
41003
  ...USERS_QUERY_KEYS.GET_USER_PLATFORM_METADATA(),
41004
+ ...USERS_QUERY_KEYS.GET_USER_ROLES(),
40845
41005
  featureTags.PLATFORM_USERS,
40846
41006
  ],
40847
41007
  endpoints: (builder) => ({
@@ -40902,7 +41062,39 @@ const userApiSlice = react.createApi({
40902
41062
  body: args,
40903
41063
  method: 'POST',
40904
41064
  }),
40905
- invalidatesTags: [featureTags.PLATFORM_USERS],
41065
+ invalidatesTags: [featureTags.PLATFORM_USERS, ...USERS_QUERY_KEYS.GET_USER_ROLES()],
41066
+ }),
41067
+ /**
41068
+ * Every role assigned to a user, across all scopes — global, organization
41069
+ * and individual course. A user with no roles returns `[]`.
41070
+ *
41071
+ * At least one of `username`, `email` or `userId` is required (400
41072
+ * otherwise). Non-staff users can only look up their own roles; asking for
41073
+ * someone else's returns 404.
41074
+ *
41075
+ * Defaults to the CMS/Studio host, where course-creation roles are fully
41076
+ * resolvable; pass `useLms` to fall back to the deprecated LMS host.
41077
+ */
41078
+ getUserRoles: builder.query({
41079
+ query: ({ username, email, userId, useLms }) => {
41080
+ const endpoint = USERS_ENDPOINTS.GET_USER_ROLES;
41081
+ const params = {};
41082
+ if (username) {
41083
+ params.username = username;
41084
+ }
41085
+ if (email) {
41086
+ params.email = email;
41087
+ }
41088
+ if (userId !== undefined) {
41089
+ params.user_id = userId;
41090
+ }
41091
+ return {
41092
+ url: endpoint.path(),
41093
+ service: useLms ? endpoint.lmsService : endpoint.service,
41094
+ params,
41095
+ };
41096
+ },
41097
+ providesTags: USERS_QUERY_KEYS.GET_USER_ROLES(),
40906
41098
  }),
40907
41099
  getUserMetadataEdx: builder.query({
40908
41100
  query: (args) => ({
@@ -40971,7 +41163,27 @@ const userApiSlice = react.createApi({
40971
41163
  }),
40972
41164
  }),
40973
41165
  });
40974
- const { useGetUserMetadataQuery, useLazyGetUserMetadataQuery, useUpdateUserMetadataMutation, useUpdateUserAccountMutation, useResetPasswordMutation, useUpdateUserRoleMutation, useUploadProfileImageMutation, useRemoveProfileImageMutation, useGetUserMetadataEdxQuery, useLazyGetUserMetadataEdxQuery, useUpdateUserMetadataEdxMutation, useGetUserPlatformMetadataQuery, useLazyGetUserPlatformMetadataQuery, useUpdateUserPlatformMetadataMutation, } = userApiSlice;
41166
+ const { useGetUserMetadataQuery, useLazyGetUserMetadataQuery, useUpdateUserMetadataMutation, useUpdateUserAccountMutation, useResetPasswordMutation, useUpdateUserRoleMutation, useGetUserRolesQuery, useLazyGetUserRolesQuery, useUploadProfileImageMutation, useRemoveProfileImageMutation, useGetUserMetadataEdxQuery, useLazyGetUserMetadataEdxQuery, useUpdateUserMetadataEdxMutation, useGetUserPlatformMetadataQuery, useLazyGetUserPlatformMetadataQuery, useUpdateUserPlatformMetadataMutation, } = userApiSlice;
41167
+
41168
+ /**
41169
+ * Scope a role row applies to, read off its `org`/`course` fields:
41170
+ * both empty → global, `org` only → org, both set → course.
41171
+ */
41172
+ const getUserRoleScope = (role) => {
41173
+ if (role.course) {
41174
+ return 'course';
41175
+ }
41176
+ if (role.org) {
41177
+ return 'org';
41178
+ }
41179
+ return 'global';
41180
+ };
41181
+ /** Roles that apply platform-wide (e.g. `staff`). */
41182
+ const filterGlobalUserRoles = (roles) => roles.filter((role) => getUserRoleScope(role) === 'global');
41183
+ /** Roles scoped to an organization, optionally narrowed to one org short name. */
41184
+ const filterOrgUserRoles = (roles, org) => roles.filter((role) => getUserRoleScope(role) === 'org' && (org === undefined || role.org === org));
41185
+ /** Roles scoped to a course run, optionally narrowed to one course ID. */
41186
+ const filterCourseUserRoles = (roles, course) => roles.filter((role) => getUserRoleScope(role) === 'course' && (course === undefined || role.course === course));
40975
41187
 
40976
41188
  /**
40977
41189
  * Shared fetch for the paginated LTI provider list endpoints. Sends
@@ -48143,7 +48355,7 @@ const skillsMiddleware = [
48143
48355
  userChatHistoryApiSlice.middleware,
48144
48356
  ];
48145
48357
 
48146
- const initializeDataLayer = (dmUrl, lmsUrl, legacyLmsUrl, storageService, httpErrorHandler = {}, studioUrl) => {
48358
+ const initializeDataLayer = (dmUrl, lmsUrl, legacyLmsUrl, storageService, httpErrorHandler = {}, studioUrl, refreshAuthToken) => {
48147
48359
  IblDataLayer.storage = storageService;
48148
48360
  Config.dmUrl = dmUrl;
48149
48361
  Config.lmsUrl = lmsUrl;
@@ -48152,6 +48364,7 @@ const initializeDataLayer = (dmUrl, lmsUrl, legacyLmsUrl, storageService, httpEr
48152
48364
  if (studioUrl) {
48153
48365
  Config.studioUrl = studioUrl;
48154
48366
  }
48367
+ Config.refreshAuthToken = refreshAuthToken;
48155
48368
  };
48156
48369
 
48157
48370
  exports.AGENT_SKILLS_ENDPOINTS = AGENT_SKILLS_ENDPOINTS;
@@ -48300,9 +48513,13 @@ exports.evalTotalPages = evalTotalPages;
48300
48513
  exports.evaluationsApiSlice = evaluationsApiSlice;
48301
48514
  exports.fakeBaseQueryWithLogging = fakeBaseQueryWithLogging;
48302
48515
  exports.featureTags = featureTags;
48516
+ exports.filterCourseUserRoles = filterCourseUserRoles;
48517
+ exports.filterGlobalUserRoles = filterGlobalUserRoles;
48518
+ exports.filterOrgUserRoles = filterOrgUserRoles;
48303
48519
  exports.filterSlashSkills = filterSlashSkills;
48304
48520
  exports.getNextRecentMessagesPageParam = getNextRecentMessagesPageParam;
48305
48521
  exports.getSpendCapExceededError = getSpendCapExceededError;
48522
+ exports.getUserRoleScope = getUserRoleScope;
48306
48523
  exports.gradingApiSlice = gradingApiSlice;
48307
48524
  exports.iblFakeBaseQuery = iblFakeBaseQuery;
48308
48525
  exports.initializeDataLayer = initializeDataLayer;
@@ -48764,6 +48981,7 @@ exports.useGetUserProjectDetailsQuery = useGetUserProjectDetailsQuery;
48764
48981
  exports.useGetUserProjectsQuery = useGetUserProjectsQuery;
48765
48982
  exports.useGetUserReportedSkillsQuery = useGetUserReportedSkillsQuery;
48766
48983
  exports.useGetUserResumeQuery = useGetUserResumeQuery;
48984
+ exports.useGetUserRolesQuery = useGetUserRolesQuery;
48767
48985
  exports.useGetUserSkillsPointsQuery = useGetUserSkillsPointsQuery;
48768
48986
  exports.useGetUserSpendCapQuery = useGetUserSpendCapQuery;
48769
48987
  exports.useGetUserTenantsQuery = useGetUserTenantsQuery;
@@ -48973,6 +49191,7 @@ exports.useLazyGetUserProjectDetailsQuery = useLazyGetUserProjectDetailsQuery;
48973
49191
  exports.useLazyGetUserProjectsQuery = useLazyGetUserProjectsQuery;
48974
49192
  exports.useLazyGetUserReportedSkillsQuery = useLazyGetUserReportedSkillsQuery;
48975
49193
  exports.useLazyGetUserResumeQuery = useLazyGetUserResumeQuery;
49194
+ exports.useLazyGetUserRolesQuery = useLazyGetUserRolesQuery;
48976
49195
  exports.useLazyGetUserSkillsPointsQuery = useLazyGetUserSkillsPointsQuery;
48977
49196
  exports.useLazyGetUserSpendCapQuery = useLazyGetUserSpendCapQuery;
48978
49197
  exports.useLazyGetUserTenantsQuery = useLazyGetUserTenantsQuery;