0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

refactor: cache well-known data

This commit is contained in:
Gao Sun 2023-03-15 21:32:08 +08:00
parent 70709044cc
commit dd91ebddfa
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
51 changed files with 356 additions and 216 deletions

View file

@ -47,7 +47,7 @@
"@logto/core-kit": "workspace:*",
"@logto/schemas": "workspace:*",
"@logto/shared": "workspace:*",
"@silverhand/essentials": "2.4.1",
"@silverhand/essentials": "^2.4.1",
"chalk": "^5.0.0",
"decamelize": "^6.0.0",
"dotenv": "^16.0.0",

View file

@ -28,7 +28,7 @@
"@logto/core-kit": "workspace:*",
"@logto/schemas": "workspace:*",
"@logto/shared": "workspace:*",
"@silverhand/essentials": "2.4.1",
"@silverhand/essentials": "^2.4.1",
"@withtyped/postgres": "^0.8.1",
"@withtyped/server": "^0.8.1",
"accepts": "^1.3.8",

View file

@ -35,7 +35,7 @@
"@parcel/transformer-svg-react": "2.8.3",
"@silverhand/eslint-config": "2.0.1",
"@silverhand/eslint-config-react": "2.0.1",
"@silverhand/essentials": "2.4.1",
"@silverhand/essentials": "^2.4.1",
"@silverhand/ts-config": "2.0.3",
"@silverhand/ts-config-react": "2.0.3",
"@tsconfig/docusaurus": "^1.0.5",

View file

@ -35,7 +35,7 @@
"@logto/phrases-ui": "workspace:*",
"@logto/schemas": "workspace:*",
"@logto/shared": "workspace:*",
"@silverhand/essentials": "2.4.1",
"@silverhand/essentials": "^2.4.1",
"aws-sdk": "^2.1329.0",
"chalk": "^5.0.0",
"clean-deep": "^3.4.0",
@ -51,6 +51,7 @@
"iconv-lite": "0.6.3",
"jose": "^4.11.0",
"js-yaml": "^4.1.0",
"keyv": "^4.5.2",
"koa": "^2.13.1",
"koa-body": "^5.0.0",
"koa-compose": "^4.1.0",
@ -63,6 +64,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",

View file

@ -0,0 +1,30 @@
import Keyv from 'keyv';
import type { AnyAsyncFunction } from 'p-memoize';
import pMemoize from 'p-memoize';
const cacheKeys = Object.freeze(['sie', 'sie-full', 'phrases', 'lng-tags'] as const);
/** Well-known data type key for cache. */
export type WellKnownCacheKey = (typeof cacheKeys)[number];
// Not sure if we need guard value for `.has()` and `.get()`,
// trust cache value for now.
const wellKnownCache = new Keyv({ ttl: 300_000 /* 5 minutes */ });
/**
* Use for centralized well-known data caching.
*
* WARN: You should store only well-known (public) data since it's a central cache.
*/
export const useWellKnownCache = <FunctionToMemoize extends AnyAsyncFunction>(
tenantId: string,
key: WellKnownCacheKey,
run: FunctionToMemoize
) =>
pMemoize(run, {
cacheKey: () => `${tenantId}:${key}`,
cache: wellKnownCache,
});
export const invalidateWellKnownCache = async (tenantId: string) =>
wellKnownCache.delete(cacheKeys.map((key) => `${tenantId}:${key}` as const));

View file

@ -11,6 +11,7 @@ import {
zhCnTag,
zhHkTag,
} from '#src/__mocks__/custom-phrase.js';
import { invalidateWellKnownCache } 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(() => {
afterEach(async () => {
await invalidateWellKnownCache(tenantId);
jest.clearAllMocks();
});

View file

@ -4,12 +4,16 @@ import type { CustomPhrase } from '@logto/schemas';
import cleanDeep from 'clean-deep';
import deepmerge from 'deepmerge';
import { useWellKnownCache } 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,18 @@ export const createPhraseLibrary = (queries: Queries) => {
);
};
return { getPhrases };
const getPhrases = useWellKnownCache(tenantId, 'phrases', _getPhrases);
const getAllCustomLanguageTags = useWellKnownCache(
tenantId,
'lng-tags',
findAllCustomLanguageTags
);
return {
/** NOTE: This function is cached by the first parameter. */
getPhrases,
/** NOTE: This function is cached. */
getAllCustomLanguageTags,
};
};

View file

@ -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();

View file

@ -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 { useWellKnownCache } 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,65 @@ export const createSignInExperienceLibrary = (
});
};
const getSignInExperience = async (): Promise<SignInExperience> => findDefaultSignInExperience();
const getSignInExperience = useWellKnownCache(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 = useWellKnownCache(tenantId, 'sie-full', _getFullSignInExperience);
return {
validateLanguageInfo,
removeUnavailableSocialConnectorTargets,
/** NOTE: This function is cached. */
getSignInExperience,
/** NOTE: This function is cached. */
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() }),
});

View file

@ -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,

View file

@ -16,5 +16,12 @@ export const createSignInExperienceQueries = (pool: CommonQueryMethods) => {
const findDefaultSignInExperience = async () =>
buildFindEntityByIdWithPool(pool)(SignInExperiences)(id);
return { updateDefaultSignInExperience, findDefaultSignInExperience };
return {
updateDefaultSignInExperience,
/**
* NOTE: Use `getSignInExperience()` from sign-in experience library
* if possible since that function leverages cache.
*/
findDefaultSignInExperience,
};
};

View file

@ -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) => {

View file

@ -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(() => {

View file

@ -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');

View file

@ -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 },
}
);

View file

@ -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;

View file

@ -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 () => {},

View file

@ -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(),

View file

@ -149,7 +149,7 @@ const parseUserProfile = async (
export default async function submitInteraction(
interaction: VerifiedInteractionResult,
ctx: WithInteractionDetailsContext,
{ provider, libraries, queries }: TenantContext,
{ provider, libraries, connectors, queries }: TenantContext,
log?: LogEntry
) {
const { hasActiveUsers, findUserById, updateUserById } = queries.users;
@ -157,7 +157,6 @@ export default async function submitInteraction(
const {
users: { generateUserId, insertUser },
connectors,
} = libraries;
const { event, profile } = interaction;

View file

@ -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),
},

View file

@ -18,7 +18,7 @@ const tenantContext = new MockTenant(
{
users: queries,
},
{ connectors: { getLogtoConnectorById } }
{ getLogtoConnectorById }
);
const findUserByIdentifier = await pickDefault(import('./find-user-by-identifier.js'));

View file

@ -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);

View file

@ -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({

View file

@ -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');

View file

@ -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[] = [

View file

@ -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'));

View file

@ -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'

View file

@ -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'));

View file

@ -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'));

View file

@ -20,6 +20,7 @@ const tenantContext = new MockTenant(
}),
},
},
undefined,
{
signInExperiences: {
validateLanguageInfo,

View file

@ -1,6 +1,9 @@
import type { SignInExperience, CreateSignInExperience } from '@logto/schemas';
import { pickDefault, createMockUtils } from '@logto/shared/esm';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createRequester } from '#src/utils/test-utils.js';
import {
mockFacebookConnector,
mockGithubConnector,
@ -17,8 +20,6 @@ import {
mockPrivacyPolicyUrl,
mockDemoSocialConnector,
} from '#src/__mocks__/index.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const { mockEsmWithActual } = createMockUtils(jest);
@ -56,15 +57,9 @@ const mockDeleteConnectorById = jest.fn();
const tenantContext = new MockTenant(
undefined,
{
signInExperiences,
customPhrases: { findAllCustomLanguageTags: async () => [] },
connectors: { deleteConnectorById: mockDeleteConnectorById },
},
{
signInExperiences: { validateLanguageInfo },
connectors: { getLogtoConnectors: mockGetLogtoConnectors },
}
{ signInExperiences, customPhrases: { findAllCustomLanguageTags: async () => [] } },
{ getLogtoConnectors: async () => logtoConnectors },
{ signInExperiences: { validateLanguageInfo } }
);
const signInExperiencesRoutes = await pickDefault(import('./index.js'));

View file

@ -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

View file

@ -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,

View file

@ -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 { invalidateWellKnownCache } 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) } }
);
@ -39,7 +41,8 @@ const phraseRequest = createRequester({
tenantContext,
});
afterEach(() => {
afterEach(async () => {
await invalidateWellKnownCache(tenantContext.id);
jest.clearAllMocks();
});

View file

@ -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 { invalidateWellKnownCache } 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(async () => {
await invalidateWellKnownCache(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);
});
});

View file

@ -10,6 +10,7 @@ import {
mockWechatConnector,
mockWechatNativeConnector,
} from '#src/__mocks__/index.js';
import { invalidateWellKnownCache } 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(() => {
afterEach(async () => {
await invalidateWellKnownCache(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);
});
});

View file

@ -1,27 +1,22 @@
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 { 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 type { AnonymousRouter, RouterInitArgs } from './types.js';
export default function wellKnownRoutes<T extends AnonymousRouter>(
...[router, { queries, libraries, id }]: RouterInitArgs<T>
...[router, { libraries, id }]: 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) {
@ -38,45 +33,24 @@ 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) => {
ctx.body = await getFullSignInExperience();
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 })),
];
}, []);
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) => {
const {
@ -85,12 +59,14 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(
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)

View file

@ -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
) {}
}

View file

@ -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();
@ -76,6 +78,7 @@ export default class Tenant implements TenantContext {
id,
provider,
queries,
connectors,
libraries,
envSet,
};

View file

@ -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;
}

View file

@ -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);
}

View file

@ -28,7 +28,7 @@
"@logto/schemas": "workspace:*",
"@peculiar/webcrypto": "^1.3.3",
"@silverhand/eslint-config": "2.0.1",
"@silverhand/essentials": "2.4.1",
"@silverhand/essentials": "^2.4.1",
"@silverhand/ts-config": "2.0.3",
"@types/expect-puppeteer": "^5.0.3",
"@types/jest": "^29.4.0",

View file

@ -34,7 +34,7 @@
},
"dependencies": {
"@logto/language-kit": "workspace:*",
"@silverhand/essentials": "2.4.1",
"@silverhand/essentials": "^2.4.1",
"zod": "^3.20.2"
},
"devDependencies": {

View file

@ -34,7 +34,7 @@
},
"dependencies": {
"@logto/language-kit": "workspace:*",
"@silverhand/essentials": "2.4.1",
"@silverhand/essentials": "^2.4.1",
"zod": "^3.20.2"
},
"devDependencies": {

View file

@ -41,7 +41,7 @@
},
"devDependencies": {
"@silverhand/eslint-config": "2.0.1",
"@silverhand/essentials": "2.4.1",
"@silverhand/essentials": "^2.4.1",
"@silverhand/ts-config": "2.0.3",
"@types/inquirer": "^9.0.0",
"@types/jest": "^29.4.0",

View file

@ -56,7 +56,7 @@
"dependencies": {
"@logto/core-kit": "workspace:*",
"@logto/schemas": "workspace:*",
"@silverhand/essentials": "2.4.1",
"@silverhand/essentials": "^2.4.1",
"chalk": "^5.0.0",
"find-up": "^6.3.0",
"nanoid": "^4.0.0",

View file

@ -33,7 +33,7 @@
},
"dependencies": {
"@logto/language-kit": "workspace:*",
"@silverhand/essentials": "2.4.1"
"@silverhand/essentials": "^2.4.1"
},
"optionalDependencies": {
"zod": "^3.20.2"

View file

@ -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(),

View file

@ -50,7 +50,7 @@
"@jest/types": "^29.0.3",
"@silverhand/eslint-config": "2.0.1",
"@silverhand/eslint-config-react": "2.0.1",
"@silverhand/essentials": "2.4.1",
"@silverhand/essentials": "^2.4.1",
"@silverhand/ts-config": "2.0.3",
"@types/color": "^3.0.3",
"@types/jest": "^29.4.0",

View file

@ -33,7 +33,7 @@
"@react-spring/web": "^9.6.1",
"@silverhand/eslint-config": "2.0.1",
"@silverhand/eslint-config-react": "2.0.1",
"@silverhand/essentials": "2.4.1",
"@silverhand/essentials": "^2.4.1",
"@silverhand/jest-config": "1.2.2",
"@silverhand/ts-config": "2.0.3",
"@silverhand/ts-config-react": "2.0.3",

37
pnpm-lock.yaml generated
View file

@ -32,7 +32,7 @@ importers:
'@logto/schemas': workspace:*
'@logto/shared': workspace:*
'@silverhand/eslint-config': 2.0.1
'@silverhand/essentials': 2.4.1
'@silverhand/essentials': ^2.4.1
'@silverhand/ts-config': 2.0.3
'@types/inquirer': ^9.0.0
'@types/jest': ^29.4.0
@ -116,7 +116,7 @@ importers:
'@logto/schemas': workspace:*
'@logto/shared': workspace:*
'@silverhand/eslint-config': 2.0.1
'@silverhand/essentials': 2.4.1
'@silverhand/essentials': ^2.4.1
'@silverhand/jest-config': ^2.0.1
'@silverhand/ts-config': 2.0.3
'@types/accepts': ^1.3.5
@ -196,7 +196,7 @@ importers:
'@parcel/transformer-svg-react': 2.8.3
'@silverhand/eslint-config': 2.0.1
'@silverhand/eslint-config-react': 2.0.1
'@silverhand/essentials': 2.4.1
'@silverhand/essentials': ^2.4.1
'@silverhand/ts-config': 2.0.3
'@silverhand/ts-config-react': 2.0.3
'@tsconfig/docusaurus': ^1.0.5
@ -346,7 +346,7 @@ importers:
'@logto/schemas': workspace:*
'@logto/shared': workspace:*
'@silverhand/eslint-config': 2.0.1
'@silverhand/essentials': 2.4.1
'@silverhand/essentials': ^2.4.1
'@silverhand/ts-config': 2.0.3
'@types/debug': ^4.1.7
'@types/etag': ^1.8.1
@ -385,6 +385,7 @@ importers:
jest-matcher-specific-error: ^1.0.0
jose: ^4.11.0
js-yaml: ^4.1.0
keyv: ^4.5.2
koa: ^2.13.1
koa-body: ^5.0.0
koa-compose: ^4.1.0
@ -401,6 +402,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
@ -442,6 +444,7 @@ importers:
iconv-lite: 0.6.3
jose: 4.11.0
js-yaml: 4.1.0
keyv: 4.5.2
koa: 2.13.4
koa-body: 5.0.0
koa-compose: 4.1.0
@ -454,6 +457,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
@ -573,7 +577,7 @@ importers:
'@logto/schemas': workspace:*
'@peculiar/webcrypto': ^1.3.3
'@silverhand/eslint-config': 2.0.1
'@silverhand/essentials': 2.4.1
'@silverhand/essentials': ^2.4.1
'@silverhand/ts-config': 2.0.3
'@types/expect-puppeteer': ^5.0.3
'@types/jest': ^29.4.0
@ -625,7 +629,7 @@ importers:
specifiers:
'@logto/language-kit': workspace:*
'@silverhand/eslint-config': 2.0.1
'@silverhand/essentials': 2.4.1
'@silverhand/essentials': ^2.4.1
'@silverhand/ts-config': 2.0.3
eslint: ^8.34.0
lint-staged: ^13.0.0
@ -648,7 +652,7 @@ importers:
specifiers:
'@logto/language-kit': workspace:*
'@silverhand/eslint-config': 2.0.1
'@silverhand/essentials': 2.4.1
'@silverhand/essentials': ^2.4.1
'@silverhand/ts-config': 2.0.3
buffer: ^5.7.1
eslint: ^8.34.0
@ -677,7 +681,7 @@ importers:
'@logto/phrases': workspace:*
'@logto/phrases-ui': workspace:*
'@silverhand/eslint-config': 2.0.1
'@silverhand/essentials': 2.4.1
'@silverhand/essentials': ^2.4.1
'@silverhand/ts-config': 2.0.3
'@types/inquirer': ^9.0.0
'@types/jest': ^29.4.0
@ -730,7 +734,7 @@ importers:
'@logto/core-kit': workspace:*
'@logto/schemas': workspace:*
'@silverhand/eslint-config': 2.0.1
'@silverhand/essentials': 2.4.1
'@silverhand/essentials': ^2.4.1
'@silverhand/ts-config': 2.0.3
'@types/jest': ^29.4.0
'@types/node': ^18.11.18
@ -767,7 +771,7 @@ importers:
specifiers:
'@logto/language-kit': workspace:*
'@silverhand/eslint-config': 2.0.1
'@silverhand/essentials': 2.4.1
'@silverhand/essentials': ^2.4.1
'@silverhand/ts-config': 2.0.3
'@types/node': ^18.11.18
eslint: ^8.34.0
@ -797,7 +801,7 @@ importers:
'@logto/language-kit': workspace:*
'@silverhand/eslint-config': 2.0.1
'@silverhand/eslint-config-react': 2.0.1
'@silverhand/essentials': 2.4.1
'@silverhand/essentials': ^2.4.1
'@silverhand/ts-config': 2.0.3
'@types/color': ^3.0.3
'@types/jest': ^29.4.0
@ -886,7 +890,7 @@ importers:
'@react-spring/web': ^9.6.1
'@silverhand/eslint-config': 2.0.1
'@silverhand/eslint-config-react': 2.0.1
'@silverhand/essentials': 2.4.1
'@silverhand/essentials': ^2.4.1
'@silverhand/jest-config': 1.2.2
'@silverhand/ts-config': 2.0.3
'@silverhand/ts-config-react': 2.0.3
@ -10638,7 +10642,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 +11236,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}