mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
Merge pull request #3430 from logto-io/gao-cache-well-known-data
refactor: cache well-known data
This commit is contained in:
commit
c7bbdb40a0
53 changed files with 612 additions and 216 deletions
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
@ -24,7 +24,8 @@ concurrency:
|
|||
jobs:
|
||||
dockerize-core:
|
||||
environment: ${{ startsWith(github.ref, 'refs/tags/') && 'release' || '' }}
|
||||
runs-on: ubuntu-latest
|
||||
# Use normal machine for OSS release since we'll build on Depot
|
||||
runs-on: ${{ startsWith(github.ref, 'refs/tags/') && 'ubuntu-latest' || 'ubuntu-latest-4-cores' }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
@ -94,7 +95,8 @@ jobs:
|
|||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
dockerize-cloud:
|
||||
runs-on: ubuntu-latest
|
||||
# Use normal machine for OSS release since we'll build on Depot
|
||||
runs-on: ${{ startsWith(github.ref, 'refs/tags/') && 'ubuntu-latest' || 'ubuntu-latest-4-cores' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
|
|
@ -63,6 +63,7 @@
|
|||
"lru-cache": "^7.14.1",
|
||||
"nanoid": "^4.0.0",
|
||||
"oidc-provider": "^8.0.0",
|
||||
"p-memoize": "^7.1.1",
|
||||
"p-retry": "^5.1.2",
|
||||
"pg-protocol": "^1.6.0",
|
||||
"roarr": "^7.11.0",
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { createServer } from 'http';
|
||||
|
||||
import { pickDefault } from '@logto/shared/esm';
|
||||
import Koa from 'koa';
|
||||
|
||||
|
@ -7,7 +9,9 @@ const initI18n = await pickDefault(import('../i18n/init.js'));
|
|||
const initApp = await pickDefault(import('./init.js'));
|
||||
|
||||
describe('App Init', () => {
|
||||
const listenMock = jest.spyOn(Koa.prototype, 'listen').mockImplementation(jest.fn());
|
||||
const listenMock = jest
|
||||
.spyOn(Koa.prototype, 'listen')
|
||||
.mockImplementation(jest.fn(() => createServer()));
|
||||
|
||||
it('app init properly with 404 not found route', async () => {
|
||||
const app = new Koa();
|
||||
|
|
|
@ -17,6 +17,8 @@ const logListening = (type: 'core' | 'admin' = 'core') => {
|
|||
}
|
||||
};
|
||||
|
||||
const serverTimeout = 120_000;
|
||||
|
||||
const getTenant = async (tenantId: string) => {
|
||||
try {
|
||||
return await tenantPool.get(tenantId);
|
||||
|
@ -76,6 +78,7 @@ export default async function initApp(app: Koa): Promise<void> {
|
|||
coreServer.listen(urlSet.port, () => {
|
||||
logListening();
|
||||
});
|
||||
coreServer.setTimeout(serverTimeout);
|
||||
|
||||
// Create another server if admin localhost enabled
|
||||
if (!adminUrlSet.isLocalhostDisabled) {
|
||||
|
@ -83,20 +86,23 @@ export default async function initApp(app: Koa): Promise<void> {
|
|||
adminServer.listen(adminUrlSet.port, () => {
|
||||
logListening('admin');
|
||||
});
|
||||
adminServer.setTimeout(serverTimeout);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Chrome doesn't allow insecure HTTP/2 servers, stick with HTTP for localhost.
|
||||
app.listen(urlSet.port, () => {
|
||||
const coreServer = app.listen(urlSet.port, () => {
|
||||
logListening();
|
||||
});
|
||||
coreServer.setTimeout(serverTimeout);
|
||||
|
||||
// Create another server if admin localhost enabled
|
||||
if (!adminUrlSet.isLocalhostDisabled) {
|
||||
app.listen(adminUrlSet.port, () => {
|
||||
const adminServer = app.listen(adminUrlSet.port, () => {
|
||||
logListening('admin');
|
||||
});
|
||||
adminServer.setTimeout(serverTimeout);
|
||||
}
|
||||
}
|
||||
|
|
58
packages/core/src/caches/well-known.ts
Normal file
58
packages/core/src/caches/well-known.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { TtlCache } from '@logto/shared';
|
||||
import type { AnyAsyncFunction } from 'p-memoize';
|
||||
import pMemoize from 'p-memoize';
|
||||
|
||||
const cacheKeys = Object.freeze(['sie', 'sie-full', 'phrases', 'phrases-lng-tags'] as const);
|
||||
|
||||
/** Well-known data type key for cache. */
|
||||
export type WellKnownCacheKey = (typeof cacheKeys)[number];
|
||||
|
||||
const buildKey = (tenantId: string, key: WellKnownCacheKey) => `${tenantId}:${key}` as const;
|
||||
|
||||
class WellKnownCache {
|
||||
#cache = new TtlCache<string, unknown>(180_000 /* 3 minutes */);
|
||||
|
||||
/**
|
||||
* Use for centralized well-known data caching.
|
||||
*
|
||||
* WARN:
|
||||
* - You should store only well-known (public) data since it's a central cache.
|
||||
* - The cache does not guard types.
|
||||
*/
|
||||
use<FunctionToMemoize extends AnyAsyncFunction>(
|
||||
tenantId: string,
|
||||
key: WellKnownCacheKey,
|
||||
run: FunctionToMemoize
|
||||
) {
|
||||
return pMemoize(run, {
|
||||
cacheKey: () => buildKey(tenantId, key),
|
||||
// Trust cache value type
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
cache: this.#cache as TtlCache<string, Awaited<ReturnType<FunctionToMemoize>>>,
|
||||
});
|
||||
}
|
||||
|
||||
invalidate(tenantId: string, keys: readonly WellKnownCacheKey[]) {
|
||||
for (const key of keys) {
|
||||
this.#cache.delete(buildKey(tenantId, key));
|
||||
}
|
||||
}
|
||||
|
||||
invalidateAll(tenantId: string) {
|
||||
this.invalidate(tenantId, cacheKeys);
|
||||
}
|
||||
|
||||
set(tenantId: string, key: WellKnownCacheKey, value: unknown) {
|
||||
this.#cache.set(buildKey(tenantId, key), value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The central TTL cache for well-known data. The default TTL is 3 minutes.
|
||||
*
|
||||
* This cache is intended for public APIs that are tolerant for data freshness.
|
||||
* For Management APIs, you should use uncached functions instead.
|
||||
*
|
||||
* WARN: You should store only well-known (public) data since it's a central cache.
|
||||
*/
|
||||
export const wellKnownCache = new WellKnownCache();
|
|
@ -11,6 +11,7 @@ import {
|
|||
zhCnTag,
|
||||
zhHkTag,
|
||||
} from '#src/__mocks__/custom-phrase.js';
|
||||
import { wellKnownCache } from '#src/caches/well-known.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { MockQueries } from '#src/test-utils/tenant.js';
|
||||
|
||||
|
@ -41,12 +42,15 @@ const findCustomPhraseByLanguageTag = jest.fn(async (languageTag: string) => {
|
|||
return mockCustomPhrase;
|
||||
});
|
||||
|
||||
const tenantId = 'mock_id';
|
||||
const { createPhraseLibrary } = await import('#src/libraries/phrase.js');
|
||||
const { getPhrases } = createPhraseLibrary(
|
||||
new MockQueries({ customPhrases: { findCustomPhraseByLanguageTag } })
|
||||
new MockQueries({ customPhrases: { findCustomPhraseByLanguageTag } }),
|
||||
tenantId
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
wellKnownCache.invalidateAll(tenantId);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
|
|
|
@ -4,12 +4,16 @@ import type { CustomPhrase } from '@logto/schemas';
|
|||
import cleanDeep from 'clean-deep';
|
||||
import deepmerge from 'deepmerge';
|
||||
|
||||
import { wellKnownCache } from '#src/caches/well-known.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
|
||||
export const createPhraseLibrary = (queries: Queries) => {
|
||||
const { findCustomPhraseByLanguageTag } = queries.customPhrases;
|
||||
export const createPhraseLibrary = (queries: Queries, tenantId: string) => {
|
||||
const { findCustomPhraseByLanguageTag, findAllCustomLanguageTags } = queries.customPhrases;
|
||||
|
||||
const getPhrases = async (supportedLanguage: string, customLanguages: string[]) => {
|
||||
const _getPhrases = async (
|
||||
supportedLanguage: string,
|
||||
customLanguages: string[]
|
||||
): Promise<LocalePhrase> => {
|
||||
if (!isBuiltInLanguageTag(supportedLanguage)) {
|
||||
return deepmerge<LocalePhrase, CustomPhrase>(
|
||||
resource.en,
|
||||
|
@ -27,5 +31,33 @@ export const createPhraseLibrary = (queries: Queries) => {
|
|||
);
|
||||
};
|
||||
|
||||
return { getPhrases };
|
||||
const getPhrases = wellKnownCache.use(tenantId, 'phrases', _getPhrases);
|
||||
|
||||
const getAllCustomLanguageTags = wellKnownCache.use(
|
||||
tenantId,
|
||||
'phrases-lng-tags',
|
||||
findAllCustomLanguageTags
|
||||
);
|
||||
|
||||
return {
|
||||
/**
|
||||
* NOTE: This function is cached by the first parameter.
|
||||
* **Cache Invalidation**
|
||||
*
|
||||
* ```ts
|
||||
* wellKnownCache.invalidate(tenantId, ['phrases']);
|
||||
* ```
|
||||
*/
|
||||
getPhrases,
|
||||
/**
|
||||
* NOTE: This function is cached.
|
||||
*
|
||||
* **Cache Invalidation**
|
||||
*
|
||||
* ```ts
|
||||
* wellKnownCache.invalidate(tenantId, ['phrases-lng-tags']);
|
||||
* ```
|
||||
*/
|
||||
getAllCustomLanguageTags,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -42,7 +42,7 @@ const getLogtoConnectors = jest.spyOn(connectorLibrary, 'getLogtoConnectors');
|
|||
|
||||
const { createSignInExperienceLibrary } = await import('./index.js');
|
||||
const { validateLanguageInfo, removeUnavailableSocialConnectorTargets } =
|
||||
createSignInExperienceLibrary(queries, connectorLibrary);
|
||||
createSignInExperienceLibrary(queries, connectorLibrary, 'mock_id');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
import { connectorMetadataGuard } from '@logto/connector-kit';
|
||||
import { builtInLanguages } from '@logto/phrases-ui';
|
||||
import type { LanguageInfo, SignInExperience } from '@logto/schemas';
|
||||
import { ConnectorType } from '@logto/schemas';
|
||||
import type { ConnectorMetadata, LanguageInfo, SignInExperience } from '@logto/schemas';
|
||||
import { SignInExperiences, ConnectorType } from '@logto/schemas';
|
||||
import { deduplicate } from '@silverhand/essentials';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { wellKnownCache } from '#src/caches/well-known.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import type { ConnectorLibrary } from '#src/libraries/connector.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
|
@ -15,12 +18,12 @@ export type SignInExperienceLibrary = ReturnType<typeof createSignInExperienceLi
|
|||
|
||||
export const createSignInExperienceLibrary = (
|
||||
queries: Queries,
|
||||
connectorLibrary: ConnectorLibrary
|
||||
{ getLogtoConnectors }: ConnectorLibrary,
|
||||
tenantId: string
|
||||
) => {
|
||||
const {
|
||||
customPhrases: { findAllCustomLanguageTags },
|
||||
signInExperiences: { findDefaultSignInExperience, updateDefaultSignInExperience },
|
||||
users: { hasActiveUsers },
|
||||
} = queries;
|
||||
|
||||
const validateLanguageInfo = async (languageInfo: LanguageInfo) => {
|
||||
|
@ -36,7 +39,7 @@ export const createSignInExperienceLibrary = (
|
|||
};
|
||||
|
||||
const removeUnavailableSocialConnectorTargets = async () => {
|
||||
const connectors = await connectorLibrary.getLogtoConnectors();
|
||||
const connectors = await getLogtoConnectors();
|
||||
const availableSocialConnectorTargets = deduplicate(
|
||||
connectors
|
||||
.filter(({ type }) => type === ConnectorType.Social)
|
||||
|
@ -52,11 +55,85 @@ export const createSignInExperienceLibrary = (
|
|||
});
|
||||
};
|
||||
|
||||
const getSignInExperience = async (): Promise<SignInExperience> => findDefaultSignInExperience();
|
||||
const getSignInExperience = wellKnownCache.use(tenantId, 'sie', findDefaultSignInExperience);
|
||||
|
||||
const _getFullSignInExperience = async (): Promise<FullSignInExperience> => {
|
||||
const [signInExperience, logtoConnectors] = await Promise.all([
|
||||
getSignInExperience(),
|
||||
getLogtoConnectors(),
|
||||
]);
|
||||
|
||||
const forgotPassword = {
|
||||
phone: logtoConnectors.some(({ type }) => type === ConnectorType.Sms),
|
||||
email: logtoConnectors.some(({ type }) => type === ConnectorType.Email),
|
||||
};
|
||||
|
||||
const socialConnectors = signInExperience.socialSignInConnectorTargets.reduce<
|
||||
Array<ConnectorMetadata & { id: string }>
|
||||
>((previous, connectorTarget) => {
|
||||
const connectors = logtoConnectors.filter(
|
||||
({ metadata: { target } }) => target === connectorTarget
|
||||
);
|
||||
|
||||
return [
|
||||
...previous,
|
||||
...connectors.map(({ metadata, dbEntry: { id } }) => ({ ...metadata, id })),
|
||||
];
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...signInExperience,
|
||||
socialConnectors,
|
||||
forgotPassword,
|
||||
};
|
||||
};
|
||||
|
||||
const getFullSignInExperience = wellKnownCache.use(
|
||||
tenantId,
|
||||
'sie-full',
|
||||
_getFullSignInExperience
|
||||
);
|
||||
|
||||
return {
|
||||
validateLanguageInfo,
|
||||
removeUnavailableSocialConnectorTargets,
|
||||
/**
|
||||
* NOTE: This function is cached.
|
||||
*
|
||||
* **Cache Invalidation**
|
||||
*
|
||||
* ```ts
|
||||
* wellKnownCache.invalidate(tenantId, ['sie']);
|
||||
* ```
|
||||
*/
|
||||
getSignInExperience,
|
||||
/**
|
||||
* NOTE: This function is cached.
|
||||
*
|
||||
* **Cache Invalidation**
|
||||
*
|
||||
* ```ts
|
||||
* wellKnownCache.invalidate(tenantId, ['sie', 'sie-full']);
|
||||
* ```
|
||||
*/
|
||||
getFullSignInExperience,
|
||||
};
|
||||
};
|
||||
|
||||
export type ForgotPassword = {
|
||||
phone: boolean;
|
||||
email: boolean;
|
||||
};
|
||||
|
||||
export type ConnectorMetadataWithId = ConnectorMetadata & { id: string };
|
||||
|
||||
export type FullSignInExperience = SignInExperience & {
|
||||
socialConnectors: ConnectorMetadataWithId[];
|
||||
forgotPassword: ForgotPassword;
|
||||
};
|
||||
|
||||
export const guardFullSignInExperience: z.ZodType<FullSignInExperience> =
|
||||
SignInExperiences.guard.extend({
|
||||
socialConnectors: connectorMetadataGuard.extend({ id: z.string() }).array(),
|
||||
forgotPassword: z.object({ phone: z.boolean(), email: z.boolean() }),
|
||||
});
|
||||
|
|
|
@ -7,6 +7,6 @@ describe('oidc provider init', () => {
|
|||
it('init should not throw', async () => {
|
||||
const { queries, libraries } = new MockTenant();
|
||||
|
||||
expect(() => initOidc(mockEnvSet, queries, libraries)).not.toThrow();
|
||||
expect(() => initOidc('mock_id', mockEnvSet, queries, libraries)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,6 +9,7 @@ import i18next from 'i18next';
|
|||
import Provider, { errors, ResourceServer } from 'oidc-provider';
|
||||
import snakecaseKeys from 'snakecase-keys';
|
||||
|
||||
import { wellKnownCache } from '#src/caches/well-known.js';
|
||||
import type { EnvSet } from '#src/env-set/index.js';
|
||||
import { addOidcEventListeners } from '#src/event-listeners/index.js';
|
||||
import koaAuditLog from '#src/middleware/koa-audit-log.js';
|
||||
|
@ -25,7 +26,12 @@ import { OIDCExtraParametersKey, InteractionMode } from './type.js';
|
|||
// Temporarily removed 'EdDSA' since it's not supported by browser yet
|
||||
const supportedSigningAlgs = Object.freeze(['RS256', 'PS256', 'ES256', 'ES384', 'ES512'] as const);
|
||||
|
||||
export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Libraries): Provider {
|
||||
export default function initOidc(
|
||||
tenantId: string,
|
||||
envSet: EnvSet,
|
||||
queries: Queries,
|
||||
libraries: Libraries
|
||||
): Provider {
|
||||
const {
|
||||
issuer,
|
||||
cookieKeys,
|
||||
|
@ -141,23 +147,24 @@ export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Li
|
|||
},
|
||||
interactions: {
|
||||
url: (ctx, interaction) => {
|
||||
const isDemoApp = interaction.params.client_id === demoAppApplicationId;
|
||||
|
||||
const appendParameters = (path: string) => {
|
||||
// `notification` is for showing a text banner on the homepage
|
||||
return interaction.params.client_id === demoAppApplicationId
|
||||
? path + `?notification=demo_app.notification`
|
||||
: path;
|
||||
return isDemoApp ? path + `?notification=demo_app.notification` : path;
|
||||
};
|
||||
|
||||
switch (interaction.prompt.name) {
|
||||
case 'login': {
|
||||
if (
|
||||
// Register user experience first
|
||||
ctx.oidc.params?.[OIDCExtraParametersKey.InteractionMode] === InteractionMode.signUp
|
||||
) {
|
||||
return appendParameters(routes.signUp);
|
||||
// Always fetch the latest sign-in experience config for demo app (live preview)
|
||||
if (isDemoApp) {
|
||||
wellKnownCache.invalidate(tenantId, ['sie', 'sie-full']);
|
||||
}
|
||||
|
||||
return appendParameters(routes.signIn.credentials);
|
||||
const isSignUp =
|
||||
ctx.oidc.params?.[OIDCExtraParametersKey.InteractionMode] === InteractionMode.signUp;
|
||||
|
||||
return appendParameters(isSignUp ? routes.signUp : routes.signIn.credentials);
|
||||
}
|
||||
|
||||
case 'consent': {
|
||||
|
|
|
@ -58,6 +58,10 @@ export const createCustomPhraseQueries = (pool: CommonQueryMethods) => {
|
|||
};
|
||||
|
||||
return {
|
||||
/**
|
||||
* NOTE: Use `getAllCustomLanguageTags()` from phrase library
|
||||
* if possible since that function leverages cache.
|
||||
*/
|
||||
findAllCustomLanguageTags,
|
||||
findAllCustomPhrases,
|
||||
findCustomPhraseByLanguageTag,
|
||||
|
|
|
@ -16,5 +16,8 @@ export const createSignInExperienceQueries = (pool: CommonQueryMethods) => {
|
|||
const findDefaultSignInExperience = async () =>
|
||||
buildFindEntityByIdWithPool(pool)(SignInExperiences)(id);
|
||||
|
||||
return { updateDefaultSignInExperience, findDefaultSignInExperience };
|
||||
return {
|
||||
updateDefaultSignInExperience,
|
||||
findDefaultSignInExperience,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -20,13 +20,11 @@ export default function socialRoutes<T extends AuthedMeRouter>(
|
|||
...[router, tenant]: RouterInitArgs<T>
|
||||
) {
|
||||
const {
|
||||
libraries: {
|
||||
connectors: { getLogtoConnectors, getLogtoConnectorById },
|
||||
},
|
||||
queries: {
|
||||
users: { findUserById, updateUserById, deleteUserIdentity, hasUserWithIdentity },
|
||||
signInExperiences: { findDefaultSignInExperience },
|
||||
},
|
||||
connectors: { getLogtoConnectors, getLogtoConnectorById },
|
||||
} = tenant;
|
||||
|
||||
router.get('/social/connectors', async (ctx, next) => {
|
||||
|
|
|
@ -105,7 +105,9 @@ const usersLibraries = {
|
|||
const adminUserRoutes = await pickDefault(import('./admin-user.js'));
|
||||
|
||||
describe('adminUserRoutes', () => {
|
||||
const tenantContext = new MockTenant(undefined, mockedQueries, { users: usersLibraries });
|
||||
const tenantContext = new MockTenant(undefined, mockedQueries, undefined, {
|
||||
users: usersLibraries,
|
||||
});
|
||||
const userRequest = createRequester({ authedRoutes: adminUserRoutes, tenantContext });
|
||||
|
||||
afterEach(() => {
|
||||
|
|
|
@ -62,6 +62,7 @@ const usersLibraries = {
|
|||
const tenantContext = new MockTenant(
|
||||
createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)),
|
||||
undefined,
|
||||
undefined,
|
||||
{ users: usersLibraries, socials: socialsLibraries }
|
||||
);
|
||||
const { createRequester } = await import('#src/utils/test-utils.js');
|
||||
|
|
|
@ -76,24 +76,24 @@ const tenantContext = new MockTenant(
|
|||
undefined,
|
||||
{ connectors: connectorQueries },
|
||||
{
|
||||
signInExperiences: { removeUnavailableSocialConnectorTargets },
|
||||
connectors: {
|
||||
getLogtoConnectors,
|
||||
getLogtoConnectorById: async (connectorId: string) => {
|
||||
const connectors = await getLogtoConnectors();
|
||||
const connector = connectors.find(({ dbEntry }) => dbEntry.id === connectorId);
|
||||
assertThat(
|
||||
connector,
|
||||
new RequestError({
|
||||
code: 'entity.not_found',
|
||||
connectorId,
|
||||
status: 404,
|
||||
})
|
||||
);
|
||||
getLogtoConnectors,
|
||||
getLogtoConnectorById: async (connectorId: string) => {
|
||||
const connectors = await getLogtoConnectors();
|
||||
const connector = connectors.find(({ dbEntry }) => dbEntry.id === connectorId);
|
||||
assertThat(
|
||||
connector,
|
||||
new RequestError({
|
||||
code: 'entity.not_found',
|
||||
connectorId,
|
||||
status: 404,
|
||||
})
|
||||
);
|
||||
|
||||
return connector;
|
||||
},
|
||||
return connector;
|
||||
},
|
||||
},
|
||||
{
|
||||
signInExperiences: { removeUnavailableSocialConnectorTargets },
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ import type { AuthedRouter, RouterInitArgs } from './types.js';
|
|||
const generateConnectorId = buildIdGenerator(12);
|
||||
|
||||
export default function connectorRoutes<T extends AuthedRouter>(
|
||||
...[router, { queries, libraries }]: RouterInitArgs<T>
|
||||
...[router, { queries, connectors, libraries }]: RouterInitArgs<T>
|
||||
) {
|
||||
const {
|
||||
findConnectorById,
|
||||
|
@ -31,8 +31,8 @@ export default function connectorRoutes<T extends AuthedRouter>(
|
|||
insertConnector,
|
||||
updateConnector,
|
||||
} = queries.connectors;
|
||||
const { getLogtoConnectorById, getLogtoConnectors } = connectors;
|
||||
const {
|
||||
connectors: { getLogtoConnectorById, getLogtoConnectors },
|
||||
signInExperiences: { removeUnavailableSocialConnectorTargets },
|
||||
} = libraries;
|
||||
|
||||
|
|
|
@ -44,10 +44,10 @@ const tenantContext = new MockTenant(
|
|||
undefined,
|
||||
{ connectors: { updateConnector } },
|
||||
{
|
||||
connectors: {
|
||||
getLogtoConnectors,
|
||||
getLogtoConnectorById,
|
||||
},
|
||||
getLogtoConnectors,
|
||||
getLogtoConnectorById,
|
||||
},
|
||||
{
|
||||
signInExperiences: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
removeUnavailableSocialConnectorTargets: async () => {},
|
||||
|
|
|
@ -56,7 +56,8 @@ describe('submit action', () => {
|
|||
const tenant = new MockTenant(
|
||||
undefined,
|
||||
{ users: userQueries, signInExperiences: { updateDefaultSignInExperience: jest.fn() } },
|
||||
{ users: userLibraries, connectors: { getLogtoConnectorById } }
|
||||
{ getLogtoConnectorById },
|
||||
{ users: userLibraries }
|
||||
);
|
||||
const ctx = {
|
||||
...createContextWithRouteParameters(),
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from '@logto/schemas';
|
||||
import { conditional, conditionalArray } from '@silverhand/essentials';
|
||||
|
||||
import { wellKnownCache } from '#src/caches/well-known.js';
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import type { ConnectorLibrary } from '#src/libraries/connector.js';
|
||||
import { assignInteractionResults } from '#src/libraries/session.js';
|
||||
|
@ -149,7 +150,7 @@ const parseUserProfile = async (
|
|||
export default async function submitInteraction(
|
||||
interaction: VerifiedInteractionResult,
|
||||
ctx: WithInteractionDetailsContext,
|
||||
{ provider, libraries, queries }: TenantContext,
|
||||
{ provider, libraries, connectors, queries, id: tenantId }: TenantContext,
|
||||
log?: LogEntry
|
||||
) {
|
||||
const { hasActiveUsers, findUserById, updateUserById } = queries.users;
|
||||
|
@ -157,7 +158,6 @@ export default async function submitInteraction(
|
|||
|
||||
const {
|
||||
users: { generateUserId, insertUser },
|
||||
connectors,
|
||||
} = libraries;
|
||||
const { event, profile } = interaction;
|
||||
|
||||
|
@ -192,6 +192,10 @@ export default async function submitInteraction(
|
|||
await updateDefaultSignInExperience({
|
||||
signInMode: isCloud ? SignInMode.SignInAndRegister : SignInMode.SignIn,
|
||||
});
|
||||
|
||||
// Normally we don't need to manually invalidate TTL cache.
|
||||
// This is for better OSS onboarding experience.
|
||||
wellKnownCache.invalidate(tenantId, ['sie', 'sie-full']);
|
||||
}
|
||||
|
||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
||||
|
|
|
@ -101,21 +101,21 @@ const tenantContext = new MockTenant(
|
|||
createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)),
|
||||
undefined,
|
||||
{
|
||||
connectors: {
|
||||
getLogtoConnectorById: async (connectorId: string) => {
|
||||
const connector = await getLogtoConnectorByIdHelper(connectorId);
|
||||
getLogtoConnectorById: async (connectorId: string) => {
|
||||
const connector = await getLogtoConnectorByIdHelper(connectorId);
|
||||
|
||||
if (connector.type !== ConnectorType.Social) {
|
||||
throw new RequestError({
|
||||
code: 'entity.not_found',
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
if (connector.type !== ConnectorType.Social) {
|
||||
throw new RequestError({
|
||||
code: 'entity.not_found',
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
return connector as LogtoConnector;
|
||||
},
|
||||
// @ts-expect-error
|
||||
return connector as LogtoConnector;
|
||||
},
|
||||
},
|
||||
{
|
||||
signInExperiences: {
|
||||
getSignInExperience: jest.fn().mockResolvedValue(mockSignInExperience),
|
||||
},
|
||||
|
|
|
@ -49,7 +49,7 @@ export type RouterContext<T> = T extends Router<unknown, infer Context> ? Contex
|
|||
export default function interactionRoutes<T extends AnonymousRouter>(
|
||||
...[anonymousRouter, tenant]: RouterInitArgs<T>
|
||||
) {
|
||||
const { provider, queries, libraries } = tenant;
|
||||
const { provider, queries, libraries, id: tenantId } = tenant;
|
||||
const router =
|
||||
// @ts-expect-error for good koa types
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
@ -68,7 +68,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
profile: profileGuard.optional(),
|
||||
}),
|
||||
}),
|
||||
koaInteractionSie(libraries.signInExperiences),
|
||||
koaInteractionSie(libraries.signInExperiences, tenantId),
|
||||
async (ctx, next) => {
|
||||
const { event, identifier, profile } = ctx.guard.body;
|
||||
const { signInExperience, createLog } = ctx;
|
||||
|
@ -118,7 +118,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
router.put(
|
||||
`${interactionPrefix}/event`,
|
||||
koaGuard({ body: z.object({ event: eventGuard }) }),
|
||||
koaInteractionSie(libraries.signInExperiences),
|
||||
koaInteractionSie(libraries.signInExperiences, tenantId),
|
||||
async (ctx, next) => {
|
||||
const { event } = ctx.guard.body;
|
||||
const { signInExperience, interactionDetails, createLog } = ctx;
|
||||
|
@ -156,7 +156,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
koaGuard({
|
||||
body: identifierPayloadGuard,
|
||||
}),
|
||||
koaInteractionSie(libraries.signInExperiences),
|
||||
koaInteractionSie(libraries.signInExperiences, tenantId),
|
||||
async (ctx, next) => {
|
||||
const identifierPayload = ctx.guard.body;
|
||||
const { signInExperience, interactionDetails, createLog } = ctx;
|
||||
|
@ -193,7 +193,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
koaGuard({
|
||||
body: profileGuard,
|
||||
}),
|
||||
koaInteractionSie(libraries.signInExperiences),
|
||||
koaInteractionSie(libraries.signInExperiences, tenantId),
|
||||
async (ctx, next) => {
|
||||
const profilePayload = ctx.guard.body;
|
||||
const { signInExperience, interactionDetails, createLog } = ctx;
|
||||
|
@ -230,7 +230,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
koaGuard({
|
||||
body: profileGuard,
|
||||
}),
|
||||
koaInteractionSie(libraries.signInExperiences),
|
||||
koaInteractionSie(libraries.signInExperiences, tenantId),
|
||||
async (ctx, next) => {
|
||||
const profilePayload = ctx.guard.body;
|
||||
const { signInExperience, interactionDetails, createLog } = ctx;
|
||||
|
@ -283,7 +283,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
// Submit Interaction
|
||||
router.post(
|
||||
`${interactionPrefix}/submit`,
|
||||
koaInteractionSie(libraries.signInExperiences),
|
||||
koaInteractionSie(libraries.signInExperiences, tenantId),
|
||||
koaInteractionHooks(tenant),
|
||||
async (ctx, next) => {
|
||||
const { interactionDetails, createLog } = ctx;
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import type { SignInExperience } from '@logto/schemas';
|
||||
import type { MiddlewareType } from 'koa';
|
||||
|
||||
import { wellKnownCache } from '#src/caches/well-known.js';
|
||||
import type { SignInExperienceLibrary } from '#src/libraries/sign-in-experience/index.js';
|
||||
import { noCache } from '#src/utils/request.js';
|
||||
|
||||
import type { WithInteractionDetailsContext } from './koa-interaction-details.js';
|
||||
|
||||
|
@ -9,14 +11,15 @@ export type WithInteractionSieContext<ContextT> = WithInteractionDetailsContext<
|
|||
signInExperience: SignInExperience;
|
||||
};
|
||||
|
||||
export default function koaInteractionSie<StateT, ContextT, ResponseT>({
|
||||
getSignInExperience,
|
||||
}: SignInExperienceLibrary): MiddlewareType<
|
||||
StateT,
|
||||
WithInteractionSieContext<ContextT>,
|
||||
ResponseT
|
||||
> {
|
||||
export default function koaInteractionSie<StateT, ContextT, ResponseT>(
|
||||
{ getSignInExperience }: SignInExperienceLibrary,
|
||||
tenantId: string
|
||||
): MiddlewareType<StateT, WithInteractionSieContext<ContextT>, ResponseT> {
|
||||
return async (ctx, next) => {
|
||||
if (noCache(ctx.headers)) {
|
||||
wellKnownCache.invalidate(tenantId, ['sie']);
|
||||
}
|
||||
|
||||
const signInExperience = await getSignInExperience();
|
||||
|
||||
ctx.signInExperience = signInExperience;
|
||||
|
|
|
@ -18,7 +18,7 @@ const tenantContext = new MockTenant(
|
|||
{
|
||||
users: queries,
|
||||
},
|
||||
{ connectors: { getLogtoConnectorById } }
|
||||
{ getLogtoConnectorById }
|
||||
);
|
||||
|
||||
const findUserByIdentifier = await pickDefault(import('./find-user-by-identifier.js'));
|
||||
|
|
|
@ -3,12 +3,12 @@ import type TenantContext from '#src/tenants/TenantContext.js';
|
|||
import type { UserIdentity } from '../types/index.js';
|
||||
|
||||
export default async function findUserByIdentifier(
|
||||
{ queries, libraries }: TenantContext,
|
||||
{ queries, connectors }: TenantContext,
|
||||
identity: UserIdentity
|
||||
) {
|
||||
const { findUserByEmail, findUserByUsername, findUserByPhone, findUserByIdentity } =
|
||||
queries.users;
|
||||
const { getLogtoConnectorById } = libraries.connectors;
|
||||
const { getLogtoConnectorById } = connectors;
|
||||
|
||||
if ('username' in identity) {
|
||||
return findUserByUsername(identity.username);
|
||||
|
|
|
@ -11,7 +11,9 @@ const { mockEsm } = createMockUtils(jest);
|
|||
|
||||
const getUserInfoByAuthCode = jest.fn().mockResolvedValue({ id: 'foo' });
|
||||
|
||||
const tenant = new MockTenant(undefined, undefined, { socials: { getUserInfoByAuthCode } });
|
||||
const tenant = new MockTenant(undefined, undefined, undefined, {
|
||||
socials: { getUserInfoByAuthCode },
|
||||
});
|
||||
|
||||
mockEsm('#src/libraries/connector.js', () => ({
|
||||
getLogtoConnectorById: jest.fn().mockResolvedValue({
|
||||
|
|
|
@ -14,12 +14,10 @@ import type { SocialAuthorizationUrlPayload } from '../types/index.js';
|
|||
|
||||
export const createSocialAuthorizationUrl = async (
|
||||
ctx: WithLogContext,
|
||||
{ provider, libraries }: TenantContext,
|
||||
{ provider, connectors }: TenantContext,
|
||||
payload: SocialAuthorizationUrlPayload
|
||||
) => {
|
||||
const {
|
||||
connectors: { getLogtoConnectorById },
|
||||
} = libraries;
|
||||
const { getLogtoConnectorById } = connectors;
|
||||
|
||||
const { connectorId, state, redirectUri } = payload;
|
||||
assertThat(state && redirectUri, 'session.insufficient_info');
|
||||
|
|
|
@ -21,11 +21,7 @@ const getLogtoConnectorById = jest.fn().mockResolvedValue({
|
|||
metadata: { target: 'logto' },
|
||||
});
|
||||
|
||||
const tenantContext = new MockTenant(
|
||||
undefined,
|
||||
{ users: userQueries },
|
||||
{ connectors: { getLogtoConnectorById } }
|
||||
);
|
||||
const tenantContext = new MockTenant(undefined, { users: userQueries }, { getLogtoConnectorById });
|
||||
const verifyProfile = await pickDefault(import('./profile-verification.js'));
|
||||
|
||||
const identifiers: Identifier[] = [
|
||||
|
|
|
@ -19,11 +19,9 @@ const tenantContext = new MockTenant(
|
|||
},
|
||||
},
|
||||
{
|
||||
connectors: {
|
||||
getLogtoConnectorById: jest.fn().mockResolvedValue({
|
||||
metadata: { target: 'logto' },
|
||||
}),
|
||||
},
|
||||
getLogtoConnectorById: jest.fn().mockResolvedValue({
|
||||
metadata: { target: 'logto' },
|
||||
}),
|
||||
}
|
||||
);
|
||||
const verifyProfile = await pickDefault(import('./profile-verification.js'));
|
||||
|
|
|
@ -58,7 +58,7 @@ const verifyProfileIdentifiers = (
|
|||
};
|
||||
|
||||
const verifyProfileNotRegisteredByOtherUserAccount = async (
|
||||
{ queries, libraries }: TenantContext,
|
||||
{ queries, connectors }: TenantContext,
|
||||
{ username, email, phone, connectorId }: Profile,
|
||||
identifiers: Identifier[] = []
|
||||
) => {
|
||||
|
@ -97,7 +97,7 @@ const verifyProfileNotRegisteredByOtherUserAccount = async (
|
|||
if (connectorId) {
|
||||
const {
|
||||
metadata: { target },
|
||||
} = await libraries.connectors.getLogtoConnectorById(connectorId);
|
||||
} = await connectors.getLogtoConnectorById(connectorId);
|
||||
|
||||
const socialIdentifier = identifiers.find(
|
||||
(identifier): identifier is SocialIdentifier => identifier.key === 'social'
|
||||
|
|
|
@ -11,11 +11,9 @@ const { mockEsmDefault } = createMockUtils(jest);
|
|||
|
||||
const findUserByIdentifier = mockEsmDefault('../utils/find-user-by-identifier.js', () => jest.fn());
|
||||
|
||||
const tenant = new MockTenant(
|
||||
undefined,
|
||||
{},
|
||||
{ socials: { findSocialRelatedUser: jest.fn().mockResolvedValue(null) } }
|
||||
);
|
||||
const tenant = new MockTenant(undefined, undefined, undefined, {
|
||||
socials: { findSocialRelatedUser: jest.fn().mockResolvedValue(null) },
|
||||
});
|
||||
|
||||
const verifyUserAccount = await pickDefault(import('./user-identity-verification.js'));
|
||||
|
||||
|
|
|
@ -52,7 +52,7 @@ mockEsm('@logto/core-kit', () => ({
|
|||
buildIdGenerator: () => () => 'randomId',
|
||||
}));
|
||||
|
||||
const tenantContext = new MockTenant(undefined, { scopes, resources }, libraries);
|
||||
const tenantContext = new MockTenant(undefined, { scopes, resources }, undefined, libraries);
|
||||
|
||||
const resourceRoutes = await pickDefault(import('./resource.js'));
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ const tenantContext = new MockTenant(
|
|||
}),
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
signInExperiences: {
|
||||
validateLanguageInfo,
|
||||
|
|
|
@ -61,10 +61,8 @@ const tenantContext = new MockTenant(
|
|||
customPhrases: { findAllCustomLanguageTags: async () => [] },
|
||||
connectors: { deleteConnectorById: mockDeleteConnectorById },
|
||||
},
|
||||
{
|
||||
signInExperiences: { validateLanguageInfo },
|
||||
connectors: { getLogtoConnectors: mockGetLogtoConnectors },
|
||||
}
|
||||
{ getLogtoConnectors: mockGetLogtoConnectors },
|
||||
{ signInExperiences: { validateLanguageInfo } }
|
||||
);
|
||||
|
||||
const signInExperiencesRoutes = await pickDefault(import('./index.js'));
|
||||
|
|
|
@ -8,14 +8,14 @@ import koaGuard from '#src/middleware/koa-guard.js';
|
|||
import type { AuthedRouter, RouterInitArgs } from '../types.js';
|
||||
|
||||
export default function signInExperiencesRoutes<T extends AuthedRouter>(
|
||||
...[router, { queries, libraries }]: RouterInitArgs<T>
|
||||
...[router, { queries, libraries, connectors }]: RouterInitArgs<T>
|
||||
) {
|
||||
const { findDefaultSignInExperience, updateDefaultSignInExperience } = queries.signInExperiences;
|
||||
const { deleteConnectorById } = queries.connectors;
|
||||
const {
|
||||
signInExperiences: { validateLanguageInfo },
|
||||
connectors: { getLogtoConnectors },
|
||||
} = libraries;
|
||||
const { getLogtoConnectors } = connectors;
|
||||
|
||||
/**
|
||||
* As we only support single signInExperience settings for V1
|
||||
|
@ -62,6 +62,8 @@ export default function signInExperiencesRoutes<T extends AuthedRouter>(
|
|||
)
|
||||
);
|
||||
|
||||
console.log('???', socialSignInConnectorTargets, filteredSocialSignInConnectorTargets);
|
||||
|
||||
if (signUp) {
|
||||
validateSignUp(signUp, connectors);
|
||||
}
|
||||
|
|
|
@ -23,13 +23,9 @@ const passcodeQueries = await mockEsmWithActual('#src/queries/passcode.js', () =
|
|||
const verificationCodeRoutes = await pickDefault(import('./verification-code.js'));
|
||||
|
||||
describe('Generic verification code flow triggered by management API', () => {
|
||||
const tenantContext = new MockTenant(
|
||||
undefined,
|
||||
{ passcodes: passcodeQueries },
|
||||
{
|
||||
passcodes: passcodeLibraries,
|
||||
}
|
||||
);
|
||||
const tenantContext = new MockTenant(undefined, { passcodes: passcodeQueries }, undefined, {
|
||||
passcodes: passcodeLibraries,
|
||||
});
|
||||
const verificationCodeRequest = createRequester({
|
||||
authedRoutes: verificationCodeRoutes,
|
||||
tenantContext,
|
||||
|
|
|
@ -4,6 +4,7 @@ import { pickDefault } from '@logto/shared/esm';
|
|||
|
||||
import { trTrTag, zhCnTag, zhHkTag } from '#src/__mocks__/custom-phrase.js';
|
||||
import { mockSignInExperience } from '#src/__mocks__/index.js';
|
||||
import { wellKnownCache } from '#src/caches/well-known.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
import { createRequester } from '#src/utils/test-utils.js';
|
||||
|
||||
|
@ -29,6 +30,7 @@ const tenantContext = new MockTenant(
|
|||
customPhrases: { findAllCustomLanguageTags: async () => [trTrTag, zhCnTag] },
|
||||
signInExperiences: { findDefaultSignInExperience },
|
||||
},
|
||||
undefined,
|
||||
{ phrases: { getPhrases: jest.fn().mockResolvedValue(en) } }
|
||||
);
|
||||
|
||||
|
@ -40,6 +42,7 @@ const phraseRequest = createRequester({
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
wellKnownCache.invalidateAll(tenantContext.id);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import { pickDefault, createMockUtils } from '@logto/shared/esm';
|
|||
|
||||
import { zhCnTag } from '#src/__mocks__/custom-phrase.js';
|
||||
import { mockSignInExperience } from '#src/__mocks__/index.js';
|
||||
import { wellKnownCache } from '#src/caches/well-known.js';
|
||||
import Queries from '#src/tenants/Queries.js';
|
||||
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
|
@ -46,6 +47,7 @@ const getPhrases = jest.fn(async () => zhCN);
|
|||
const tenantContext = new MockTenant(
|
||||
createMockProvider(),
|
||||
{ customPhrases, signInExperiences: { findDefaultSignInExperience } },
|
||||
undefined,
|
||||
{ phrases: { getPhrases } }
|
||||
);
|
||||
|
||||
|
@ -57,11 +59,12 @@ const phraseRequest = createRequester({
|
|||
tenantContext,
|
||||
});
|
||||
|
||||
describe('when the application is not admin-console', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
wellKnownCache.invalidateAll(tenantContext.id);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when the application is not admin-console', () => {
|
||||
it('should call findDefaultSignInExperience', async () => {
|
||||
await expect(phraseRequest.get('/.well-known/phrases')).resolves.toHaveProperty('status', 200);
|
||||
expect(findDefaultSignInExperience).toBeCalledTimes(1);
|
||||
|
@ -123,4 +126,16 @@ describe('when the application is not admin-console', () => {
|
|||
);
|
||||
expect(getPhrases).toBeCalledWith('fr', [customizedLanguage]);
|
||||
});
|
||||
|
||||
it('should use cache for continuous requests', async () => {
|
||||
const [response1, response2, response3] = await Promise.all([
|
||||
phraseRequest.get('/.well-known/phrases'),
|
||||
phraseRequest.get('/.well-known/phrases'),
|
||||
phraseRequest.get('/.well-known/phrases'),
|
||||
]);
|
||||
expect(findDefaultSignInExperience).toHaveBeenCalledTimes(1);
|
||||
expect(findAllCustomLanguageTags).toHaveBeenCalledTimes(1);
|
||||
expect(response1.body).toStrictEqual(response2.body);
|
||||
expect(response1.body).toStrictEqual(response3.body);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
mockWechatConnector,
|
||||
mockWechatNativeConnector,
|
||||
} from '#src/__mocks__/index.js';
|
||||
import { wellKnownCache } from '#src/caches/well-known.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsm } = createMockUtils(jest);
|
||||
|
@ -32,34 +33,36 @@ const { createMockProvider } = await import('#src/test-utils/oidc-provider.js');
|
|||
const { MockTenant } = await import('#src/test-utils/tenant.js');
|
||||
const { createRequester } = await import('#src/utils/test-utils.js');
|
||||
|
||||
const provider = createMockProvider();
|
||||
const getLogtoConnectors = jest.fn(async () => {
|
||||
return [
|
||||
mockAliyunDmConnector,
|
||||
mockAliyunSmsConnector,
|
||||
mockFacebookConnector,
|
||||
mockGithubConnector,
|
||||
mockGoogleConnector,
|
||||
mockWechatConnector,
|
||||
mockWechatNativeConnector,
|
||||
];
|
||||
});
|
||||
const tenantContext = new MockTenant(
|
||||
provider,
|
||||
{
|
||||
signInExperiences: sieQueries,
|
||||
users: { hasActiveUsers: jest.fn().mockResolvedValue(true) },
|
||||
},
|
||||
{ getLogtoConnectors }
|
||||
);
|
||||
|
||||
describe('GET /.well-known/sign-in-exp', () => {
|
||||
afterEach(() => {
|
||||
wellKnownCache.invalidateAll(tenantContext.id);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const provider = createMockProvider();
|
||||
const sessionRequest = createRequester({
|
||||
anonymousRoutes: wellKnownRoutes,
|
||||
tenantContext: new MockTenant(
|
||||
provider,
|
||||
{
|
||||
signInExperiences: sieQueries,
|
||||
users: { hasActiveUsers: jest.fn().mockResolvedValue(true) },
|
||||
},
|
||||
{
|
||||
connectors: {
|
||||
getLogtoConnectors: jest.fn(async () => [
|
||||
mockAliyunDmConnector,
|
||||
mockAliyunSmsConnector,
|
||||
mockFacebookConnector,
|
||||
mockGithubConnector,
|
||||
mockGoogleConnector,
|
||||
mockWechatConnector,
|
||||
mockWechatNativeConnector,
|
||||
]),
|
||||
},
|
||||
}
|
||||
),
|
||||
tenantContext,
|
||||
middlewares: [
|
||||
async (ctx, next) => {
|
||||
ctx.addLogContext = jest.fn();
|
||||
|
@ -96,4 +99,16 @@ describe('GET /.well-known/sign-in-exp', () => {
|
|||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should use cache for continuous requests', async () => {
|
||||
const [response1, response2, response3] = await Promise.all([
|
||||
sessionRequest.get('/.well-known/sign-in-exp'),
|
||||
sessionRequest.get('/.well-known/sign-in-exp'),
|
||||
sessionRequest.get('/.well-known/sign-in-exp'),
|
||||
]);
|
||||
expect(findDefaultSignInExperience).toHaveBeenCalledTimes(1);
|
||||
expect(getLogtoConnectors).toHaveBeenCalledTimes(1);
|
||||
expect(response1.body).toStrictEqual(response2.body);
|
||||
expect(response2.body).toStrictEqual(response3.body);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,30 +1,27 @@
|
|||
import type { ConnectorMetadata } from '@logto/connector-kit';
|
||||
import { ConnectorType } from '@logto/connector-kit';
|
||||
import { isBuiltInLanguageTag } from '@logto/phrases-ui';
|
||||
import { adminTenantId } from '@logto/schemas';
|
||||
import { object, string } from 'zod';
|
||||
import { conditionalArray } from '@silverhand/essentials';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { wellKnownCache } from '#src/caches/well-known.js';
|
||||
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import detectLanguage from '#src/i18n/detect-language.js';
|
||||
import { guardFullSignInExperience } from '#src/libraries/sign-in-experience/index.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import { noCache } from '#src/utils/request.js';
|
||||
|
||||
import type { AnonymousRouter, RouterInitArgs } from './types.js';
|
||||
|
||||
export default function wellKnownRoutes<T extends AnonymousRouter>(
|
||||
...[router, { queries, libraries, id }]: RouterInitArgs<T>
|
||||
...[router, { libraries, id: tenantId }]: RouterInitArgs<T>
|
||||
) {
|
||||
const {
|
||||
customPhrases: { findAllCustomLanguageTags },
|
||||
signInExperiences: { findDefaultSignInExperience },
|
||||
} = queries;
|
||||
const {
|
||||
signInExperiences: { getSignInExperience },
|
||||
connectors: { getLogtoConnectors },
|
||||
phrases: { getPhrases },
|
||||
signInExperiences: { getSignInExperience, getFullSignInExperience },
|
||||
phrases: { getPhrases, getAllCustomLanguageTags },
|
||||
} = libraries;
|
||||
|
||||
if (id === adminTenantId) {
|
||||
if (tenantId === adminTenantId) {
|
||||
router.get('/.well-known/endpoints/:tenantId', async (ctx, next) => {
|
||||
if (!ctx.params.tenantId) {
|
||||
throw new RequestError('request.invalid_input');
|
||||
|
@ -38,59 +35,48 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(
|
|||
});
|
||||
}
|
||||
|
||||
router.get('/.well-known/sign-in-exp', async (ctx, next) => {
|
||||
const [signInExperience, logtoConnectors] = await Promise.all([
|
||||
getSignInExperience(),
|
||||
getLogtoConnectors(),
|
||||
]);
|
||||
router.get(
|
||||
'/.well-known/sign-in-exp',
|
||||
koaGuard({ response: guardFullSignInExperience, status: 200 }),
|
||||
async (ctx, next) => {
|
||||
if (noCache(ctx.headers)) {
|
||||
wellKnownCache.invalidate(tenantId, ['sie', 'sie-full']);
|
||||
}
|
||||
|
||||
const forgotPassword = {
|
||||
phone: logtoConnectors.some(({ type }) => type === ConnectorType.Sms),
|
||||
email: logtoConnectors.some(({ type }) => type === ConnectorType.Email),
|
||||
};
|
||||
ctx.body = await getFullSignInExperience();
|
||||
|
||||
const socialConnectors = signInExperience.socialSignInConnectorTargets.reduce<
|
||||
Array<ConnectorMetadata & { id: string }>
|
||||
>((previous, connectorTarget) => {
|
||||
const connectors = logtoConnectors.filter(
|
||||
({ metadata: { target } }) => target === connectorTarget
|
||||
);
|
||||
|
||||
return [
|
||||
...previous,
|
||||
...connectors.map(({ metadata, dbEntry: { id } }) => ({ ...metadata, id })),
|
||||
];
|
||||
}, []);
|
||||
|
||||
ctx.body = {
|
||||
...signInExperience,
|
||||
socialConnectors,
|
||||
forgotPassword,
|
||||
};
|
||||
|
||||
return next();
|
||||
});
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/.well-known/phrases',
|
||||
koaGuard({
|
||||
query: object({
|
||||
lng: string().optional(),
|
||||
query: z.object({
|
||||
lng: z.string().optional(),
|
||||
}),
|
||||
response: z.record(z.string().or(z.record(z.unknown()))),
|
||||
status: 200,
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
if (noCache(ctx.headers)) {
|
||||
wellKnownCache.invalidate(tenantId, ['sie', 'phrases-lng-tags', 'phrases']);
|
||||
}
|
||||
|
||||
const {
|
||||
query: { lng },
|
||||
} = ctx.guard;
|
||||
|
||||
const {
|
||||
languageInfo: { autoDetect, fallbackLanguage },
|
||||
} = await findDefaultSignInExperience();
|
||||
} = await getSignInExperience();
|
||||
|
||||
const targetLanguage = lng ? [lng] : [];
|
||||
const detectedLanguages = autoDetect ? detectLanguage(ctx) : [];
|
||||
const acceptableLanguages = [...targetLanguage, ...detectedLanguages, fallbackLanguage];
|
||||
const customLanguages = await findAllCustomLanguageTags();
|
||||
const acceptableLanguages = conditionalArray<string | string[]>(
|
||||
lng,
|
||||
autoDetect && detectLanguage(ctx),
|
||||
fallbackLanguage
|
||||
);
|
||||
const customLanguages = await getAllCustomLanguageTags();
|
||||
const language =
|
||||
acceptableLanguages.find(
|
||||
(tag) => isBuiltInLanguageTag(tag) || customLanguages.includes(tag)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { createApplicationLibrary } from '#src/libraries/application.js';
|
||||
import { createConnectorLibrary } from '#src/libraries/connector.js';
|
||||
import type { ConnectorLibrary } from '#src/libraries/connector.js';
|
||||
import { createHookLibrary } from '#src/libraries/hook.js';
|
||||
import { createPasscodeLibrary } from '#src/libraries/passcode.js';
|
||||
import { createPhraseLibrary } from '#src/libraries/phrase.js';
|
||||
|
@ -12,10 +12,9 @@ import { createVerificationStatusLibrary } from '#src/libraries/verification-sta
|
|||
import type Queries from './Queries.js';
|
||||
|
||||
export default class Libraries {
|
||||
connectors = createConnectorLibrary(this.queries);
|
||||
users = createUserLibrary(this.queries);
|
||||
signInExperiences = createSignInExperienceLibrary(this.queries, this.connectors);
|
||||
phrases = createPhraseLibrary(this.queries);
|
||||
signInExperiences = createSignInExperienceLibrary(this.queries, this.connectors, this.tenantId);
|
||||
phrases = createPhraseLibrary(this.queries, this.tenantId);
|
||||
resources = createResourceLibrary(this.queries);
|
||||
hooks = createHookLibrary(this.queries);
|
||||
socials = createSocialLibrary(this.queries, this.connectors);
|
||||
|
@ -23,5 +22,10 @@ export default class Libraries {
|
|||
applications = createApplicationLibrary(this.queries);
|
||||
verificationStatuses = createVerificationStatusLibrary(this.queries);
|
||||
|
||||
constructor(private readonly queries: Queries) {}
|
||||
constructor(
|
||||
public readonly tenantId: string,
|
||||
private readonly queries: Queries,
|
||||
// Explicitly passing connector library to eliminate dependency issue
|
||||
private readonly connectors: ConnectorLibrary
|
||||
) {}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import mount from 'koa-mount';
|
|||
import type Provider from 'oidc-provider';
|
||||
|
||||
import { AdminApps, EnvSet, UserApps } from '#src/env-set/index.js';
|
||||
import { createConnectorLibrary } from '#src/libraries/connector.js';
|
||||
import koaConnectorErrorHandler from '#src/middleware/koa-connector-error-handler.js';
|
||||
import koaConsoleRedirectProxy from '#src/middleware/koa-console-redirect-proxy.js';
|
||||
import koaErrorHandler from '#src/middleware/koa-error-handler.js';
|
||||
|
@ -38,15 +39,18 @@ export default class Tenant implements TenantContext {
|
|||
#onRequestEmpty?: () => Promise<void>;
|
||||
|
||||
public readonly provider: Provider;
|
||||
public readonly queries: Queries;
|
||||
public readonly libraries: Libraries;
|
||||
public readonly run: MiddlewareType;
|
||||
|
||||
private readonly app: Koa;
|
||||
|
||||
private constructor(public readonly envSet: EnvSet, public readonly id: string) {
|
||||
const queries = new Queries(envSet.pool);
|
||||
const libraries = new Libraries(queries);
|
||||
// eslint-disable-next-line max-params
|
||||
private constructor(
|
||||
public readonly envSet: EnvSet,
|
||||
public readonly id: string,
|
||||
public readonly queries = new Queries(envSet.pool),
|
||||
public readonly connectors = createConnectorLibrary(queries),
|
||||
public readonly libraries = new Libraries(id, queries, connectors)
|
||||
) {
|
||||
const isAdminTenant = id === adminTenantId;
|
||||
const mountedApps = [
|
||||
...Object.values(UserApps),
|
||||
|
@ -54,8 +58,6 @@ export default class Tenant implements TenantContext {
|
|||
];
|
||||
|
||||
this.envSet = envSet;
|
||||
this.queries = queries;
|
||||
this.libraries = libraries;
|
||||
|
||||
// Init app
|
||||
const app = new Koa();
|
||||
|
@ -69,13 +71,14 @@ export default class Tenant implements TenantContext {
|
|||
app.use(koaCompress());
|
||||
|
||||
// Mount OIDC
|
||||
const provider = initOidc(envSet, queries, libraries);
|
||||
const provider = initOidc(id, envSet, queries, libraries);
|
||||
app.use(mount('/oidc', provider.app));
|
||||
|
||||
const tenantContext: TenantContext = {
|
||||
id,
|
||||
provider,
|
||||
queries,
|
||||
connectors,
|
||||
libraries,
|
||||
envSet,
|
||||
};
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import type Provider from 'oidc-provider';
|
||||
|
||||
import type { EnvSet } from '#src/env-set/index.js';
|
||||
import type { ConnectorLibrary } from '#src/libraries/connector.js';
|
||||
|
||||
import type Libraries from './Libraries.js';
|
||||
import type Queries from './Queries.js';
|
||||
|
@ -10,5 +11,6 @@ export default abstract class TenantContext {
|
|||
public abstract readonly envSet: EnvSet;
|
||||
public abstract readonly provider: Provider;
|
||||
public abstract readonly queries: Queries;
|
||||
public abstract readonly connectors: ConnectorLibrary;
|
||||
public abstract readonly libraries: Libraries;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { createMockPool, createMockQueryResult } from 'slonik';
|
||||
|
||||
import type { ConnectorLibrary } from '#src/libraries/connector.js';
|
||||
import { createConnectorLibrary } from '#src/libraries/connector.js';
|
||||
import Libraries from '#src/tenants/Libraries.js';
|
||||
import Queries from '#src/tenants/Queries.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||
|
@ -46,15 +48,18 @@ export class MockTenant implements TenantContext {
|
|||
public id = 'mock_id';
|
||||
public envSet = mockEnvSet;
|
||||
public queries: Queries;
|
||||
public connectors: ConnectorLibrary;
|
||||
public libraries: Libraries;
|
||||
|
||||
constructor(
|
||||
public provider = createMockProvider(),
|
||||
queriesOverride?: Partial2<Queries>,
|
||||
connectorsOverride?: Partial<ConnectorLibrary>,
|
||||
librariesOverride?: Partial2<Libraries>
|
||||
) {
|
||||
this.queries = new MockQueries(queriesOverride);
|
||||
this.libraries = new Libraries(this.queries);
|
||||
this.connectors = { ...createConnectorLibrary(this.queries), ...connectorsOverride };
|
||||
this.libraries = new Libraries(this.id, this.queries, this.connectors);
|
||||
this.setPartial('libraries', librariesOverride);
|
||||
}
|
||||
|
||||
|
|
5
packages/core/src/utils/request.ts
Normal file
5
packages/core/src/utils/request.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import type { IncomingHttpHeaders } from 'http';
|
||||
|
||||
export const noCache = (headers: IncomingHttpHeaders): boolean =>
|
||||
headers['cache-control']?.split(',').some((value) => value.trim().toLowerCase() === 'no-cache') ??
|
||||
false;
|
|
@ -2,7 +2,10 @@ import { got } from 'got';
|
|||
|
||||
import { logtoConsoleUrl, logtoUrl } from '#src/constants.js';
|
||||
|
||||
const api = got.extend({ prefixUrl: new URL('/api', logtoUrl) });
|
||||
const api = got.extend({
|
||||
prefixUrl: new URL('/api', logtoUrl),
|
||||
headers: { 'cache-control': 'no-cache' },
|
||||
});
|
||||
|
||||
export default api;
|
||||
|
||||
|
@ -13,7 +16,10 @@ export const authedAdminApi = api.extend({
|
|||
},
|
||||
});
|
||||
|
||||
export const adminTenantApi = got.extend({ prefixUrl: new URL('/api', logtoConsoleUrl) });
|
||||
export const adminTenantApi = got.extend({
|
||||
prefixUrl: new URL('/api', logtoConsoleUrl),
|
||||
headers: { 'cache-control': 'no-cache' },
|
||||
});
|
||||
|
||||
export const authedAdminTenantApi = adminTenantApi.extend({
|
||||
headers: {
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import type { SignInExperience } from '@logto/schemas';
|
||||
|
||||
import { adminTenantApi } from '#src/api/api.js';
|
||||
import { adminTenantApi, authedAdminApi } from '#src/api/api.js';
|
||||
import { api } from '#src/api/index.js';
|
||||
import { generateUserId } from '#src/utils.js';
|
||||
|
||||
describe('.well-known api', () => {
|
||||
it('get /.well-known/sign-in-exp for console', async () => {
|
||||
|
@ -33,4 +34,19 @@ describe('.well-known api', () => {
|
|||
// Should support sign-in and register
|
||||
expect(response).toMatchObject({ signInMode: 'SignInAndRegister' });
|
||||
});
|
||||
|
||||
it('should use cached version if no-cache header is not present', async () => {
|
||||
const response1 = await api.get('.well-known/sign-in-exp').json<SignInExperience>();
|
||||
|
||||
const randomId = generateUserId();
|
||||
const customContent = { foo: randomId };
|
||||
await authedAdminApi.patch('sign-in-exp', { json: { customContent } }).json<SignInExperience>();
|
||||
|
||||
const response2 = await api
|
||||
.get('.well-known/sign-in-exp', { headers: { 'cache-control': '' } })
|
||||
.json<SignInExperience>();
|
||||
|
||||
expect(response2.customContent.foo).not.toBe(randomId);
|
||||
expect(response2).toStrictEqual(response1);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from './function.js';
|
||||
export * from './object.js';
|
||||
export { default as findPackage } from './find-package.js';
|
||||
export * from './ttl-cache.js';
|
||||
|
|
84
packages/shared/src/utils/ttl-cache.test.ts
Normal file
84
packages/shared/src/utils/ttl-cache.test.ts
Normal file
|
@ -0,0 +1,84 @@
|
|||
import { TtlCache } from './ttl-cache.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
describe('TtlCache', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return cached value after a long time if ttl is not set', () => {
|
||||
jest.setSystemTime(0);
|
||||
|
||||
const cache = new TtlCache();
|
||||
const someObject = Object.freeze({ foo: 'bar', baz: 123 });
|
||||
|
||||
cache.set('foo', someObject);
|
||||
|
||||
jest.setSystemTime(100_000_000);
|
||||
expect(cache.get('foo')).toBe(someObject);
|
||||
expect(cache.has('foo')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return cached value and honor ttl', () => {
|
||||
jest.setSystemTime(0);
|
||||
|
||||
const cache = new TtlCache(100);
|
||||
const someObject = Object.freeze({ foo: 'bar', baz: 123 });
|
||||
|
||||
cache.set(123, someObject);
|
||||
cache.set('foo', someObject, 99);
|
||||
|
||||
jest.setSystemTime(100);
|
||||
expect(cache.get(123)).toBe(someObject);
|
||||
expect(cache.has(123)).toBe(true);
|
||||
expect(cache.get('123')).toBe(undefined);
|
||||
expect(cache.has('123')).toBe(false);
|
||||
expect(cache.get('foo')).toBe(undefined);
|
||||
expect(cache.has('foo')).toBe(false);
|
||||
|
||||
jest.setSystemTime(101);
|
||||
expect(cache.get(123)).toBe(undefined);
|
||||
expect(cache.has(123)).toBe(false);
|
||||
});
|
||||
|
||||
it('should be able to delete value before ttl', () => {
|
||||
const cache = new TtlCache(100);
|
||||
const someObject = Object.freeze({ foo: 'bar', baz: 123 });
|
||||
|
||||
cache.set('foo', someObject);
|
||||
cache.delete('foo');
|
||||
cache.delete('bar');
|
||||
|
||||
expect(cache.get('foo')).toBe(undefined);
|
||||
expect(cache.has('foo')).toBe(false);
|
||||
});
|
||||
|
||||
it('should be able to clear all values', () => {
|
||||
const cache = new TtlCache(100);
|
||||
const someObject = Object.freeze({ foo: 'bar', baz: 123 });
|
||||
|
||||
cache.set('foo', someObject);
|
||||
cache.set('bar', someObject);
|
||||
cache.set(123, 456);
|
||||
cache.clear();
|
||||
|
||||
expect(cache.get('foo')).toBe(undefined);
|
||||
expect(cache.has('foo')).toBe(false);
|
||||
expect(cache.get('bar')).toBe(undefined);
|
||||
expect(cache.has('bar')).toBe(false);
|
||||
expect(cache.get(123)).toBe(undefined);
|
||||
expect(cache.has(123)).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw undefined when value is undefined', () => {
|
||||
const cache = new TtlCache();
|
||||
expect(() => {
|
||||
cache.set(1, undefined);
|
||||
}).toThrow(TypeError);
|
||||
});
|
||||
});
|
46
packages/shared/src/utils/ttl-cache.ts
Normal file
46
packages/shared/src/utils/ttl-cache.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
export class TtlCache<Key, Value> {
|
||||
data = new Map<Key, Value>();
|
||||
expiration = new Map<Key, number>();
|
||||
|
||||
constructor(public readonly ttl = Number.POSITIVE_INFINITY) {}
|
||||
|
||||
#purge(key: Key) {
|
||||
const expiration = this.expiration.get(key);
|
||||
|
||||
if (expiration !== undefined && expiration < Date.now()) {
|
||||
this.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
set(key: Key, value: Value, ttl = this.ttl) {
|
||||
if (value === undefined) {
|
||||
throw new TypeError('Value cannot be undefined');
|
||||
}
|
||||
|
||||
this.expiration.set(key, Date.now() + ttl);
|
||||
this.data.set(key, value);
|
||||
}
|
||||
|
||||
get(key: Key): Value | undefined {
|
||||
this.#purge(key);
|
||||
|
||||
return this.data.get(key);
|
||||
}
|
||||
|
||||
has(key: Key) {
|
||||
this.#purge(key);
|
||||
|
||||
return this.data.has(key);
|
||||
}
|
||||
|
||||
delete(key: Key) {
|
||||
this.expiration.delete(key);
|
||||
|
||||
return this.data.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.expiration.clear();
|
||||
this.data.clear();
|
||||
}
|
||||
}
|
|
@ -136,7 +136,7 @@ const connectorConfigFormItemGuard = z.discriminatedUnion('type', [
|
|||
|
||||
export type ConnectorConfigFormItem = z.infer<typeof connectorConfigFormItemGuard>;
|
||||
|
||||
const connectorMetadataGuard = z.object({
|
||||
export const connectorMetadataGuard = z.object({
|
||||
id: z.string(),
|
||||
target: z.string(),
|
||||
platform: z.nativeEnum(ConnectorPlatform).nullable(),
|
||||
|
|
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
|
@ -401,6 +401,7 @@ importers:
|
|||
nodemon: ^2.0.19
|
||||
oidc-provider: ^8.0.0
|
||||
openapi-types: ^12.0.0
|
||||
p-memoize: ^7.1.1
|
||||
p-retry: ^5.1.2
|
||||
pg-protocol: ^1.6.0
|
||||
prettier: ^2.8.2
|
||||
|
@ -454,6 +455,7 @@ importers:
|
|||
lru-cache: 7.14.1
|
||||
nanoid: 4.0.0
|
||||
oidc-provider: 8.0.0
|
||||
p-memoize: 7.1.1
|
||||
p-retry: 5.1.2
|
||||
pg-protocol: 1.6.0
|
||||
roarr: 7.11.0
|
||||
|
@ -10638,7 +10640,6 @@ packages:
|
|||
/mimic-fn/4.0.0:
|
||||
resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
|
||||
engines: {node: '>=12'}
|
||||
dev: true
|
||||
|
||||
/mimic-response/3.1.0:
|
||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||
|
@ -11233,6 +11234,14 @@ packages:
|
|||
aggregate-error: 3.1.0
|
||||
dev: true
|
||||
|
||||
/p-memoize/7.1.1:
|
||||
resolution: {integrity: sha512-DZ/bONJILHkQ721hSr/E9wMz5Am/OTJ9P6LhLFo2Tu+jL8044tgc9LwHO8g4PiaYePnlVVRAJcKmgy8J9MVFrA==}
|
||||
engines: {node: '>=14.16'}
|
||||
dependencies:
|
||||
mimic-fn: 4.0.0
|
||||
type-fest: 3.5.2
|
||||
dev: false
|
||||
|
||||
/p-retry/5.1.2:
|
||||
resolution: {integrity: sha512-couX95waDu98NfNZV+i/iLt+fdVxmI7CbrrdC2uDWfPdUAApyxT4wmDlyOtR5KtTDmkDO0zDScDjDou9YHhd9g==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
|
Loading…
Add table
Reference in a new issue