@shopify/shopify-app-remix 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/react/components/AppProvider/AppProvider.js.map +1 -1
- package/dist/cjs/react/components/AppProxyLink/AppProxyLink.js.map +1 -1
- package/dist/cjs/server/authenticate/admin/strategies/auth-code-flow.js.map +1 -1
- package/dist/cjs/server/authenticate/admin/strategies/merchant-custom-app.js.map +1 -1
- package/dist/cjs/server/authenticate/admin/strategies/token-exchange.js.map +1 -1
- package/dist/cjs/server/authenticate/webhooks/authenticate.js +34 -10
- package/dist/cjs/server/authenticate/webhooks/authenticate.js.map +1 -1
- package/dist/cjs/server/version.js +1 -1
- package/dist/cjs/server/version.js.map +1 -1
- package/dist/esm/react/components/AppProvider/AppProvider.mjs.map +1 -1
- package/dist/esm/react/components/AppProxyLink/AppProxyLink.mjs.map +1 -1
- package/dist/esm/server/authenticate/admin/strategies/auth-code-flow.mjs.map +1 -1
- package/dist/esm/server/authenticate/admin/strategies/merchant-custom-app.mjs.map +1 -1
- package/dist/esm/server/authenticate/admin/strategies/token-exchange.mjs.map +1 -1
- package/dist/esm/server/authenticate/webhooks/authenticate.mjs +35 -11
- package/dist/esm/server/authenticate/webhooks/authenticate.mjs.map +1 -1
- package/dist/esm/server/version.mjs +1 -1
- package/dist/esm/server/version.mjs.map +1 -1
- package/dist/ts/react/components/AppProvider/AppProvider.d.ts.map +1 -1
- package/dist/ts/react/components/AppProxyLink/AppProxyLink.d.ts.map +1 -1
- package/dist/ts/server/authenticate/admin/billing/types.d.ts.map +1 -1
- package/dist/ts/server/authenticate/admin/strategies/auth-code-flow.d.ts.map +1 -1
- package/dist/ts/server/authenticate/admin/strategies/merchant-custom-app.d.ts.map +1 -1
- package/dist/ts/server/authenticate/admin/strategies/token-exchange.d.ts.map +1 -1
- package/dist/ts/server/authenticate/admin/types.d.ts.map +1 -1
- package/dist/ts/server/authenticate/public/extension/types.d.ts.map +1 -1
- package/dist/ts/server/authenticate/webhooks/authenticate.d.ts.map +1 -1
- package/dist/ts/server/authenticate/webhooks/types.d.ts +34 -2
- package/dist/ts/server/authenticate/webhooks/types.d.ts.map +1 -1
- package/dist/ts/server/config-types.d.ts.map +1 -1
- package/dist/ts/server/version.d.ts +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +18 -14
- package/src/react/components/AppProvider/AppProvider.doc.ts +35 -0
- package/src/react/components/AppProvider/AppProvider.tsx +147 -0
- package/src/react/components/AppProvider/index.ts +1 -0
- package/src/react/components/AppProxyForm/AppProxyForm.doc.ts +28 -0
- package/src/react/components/AppProxyForm/AppProxyForm.tsx +82 -0
- package/src/react/components/AppProxyForm/index.ts +1 -0
- package/src/react/components/AppProxyLink/AppProxyLink.doc.ts +28 -0
- package/src/react/components/AppProxyLink/AppProxyLink.tsx +58 -0
- package/src/react/components/AppProxyLink/index.ts +1 -0
- package/src/react/components/AppProxyProvider/AppProxyProvider.doc.ts +40 -0
- package/src/react/components/AppProxyProvider/AppProxyProvider.tsx +100 -0
- package/src/react/components/AppProxyProvider/index.ts +5 -0
- package/src/react/components/RemixPolarisLink.tsx +14 -0
- package/src/react/components/index.ts +4 -0
- package/src/react/const.ts +2 -0
- package/src/react/index.ts +1 -0
- package/src/server/__test-helpers/const.ts +14 -0
- package/src/server/__test-helpers/expect-admin-api-client.ts +51 -0
- package/src/server/__test-helpers/expect-begin-auth-redirect.ts +22 -0
- package/src/server/__test-helpers/expect-document-request-headers.ts +25 -0
- package/src/server/__test-helpers/expect-exit-iframe.ts +37 -0
- package/src/server/__test-helpers/expect-login-redirect.ts +8 -0
- package/src/server/__test-helpers/expect-storefront-api-client.ts +49 -0
- package/src/server/__test-helpers/expect-token-refresh.ts +188 -0
- package/src/server/__test-helpers/get-hmac.ts +7 -0
- package/src/server/__test-helpers/get-jwt.ts +10 -0
- package/src/server/__test-helpers/get-thrown-error.ts +19 -0
- package/src/server/__test-helpers/get-thrown-response.ts +17 -0
- package/src/server/__test-helpers/index.ts +21 -0
- package/src/server/__test-helpers/mock-graphql-request.ts +60 -0
- package/src/server/__test-helpers/request-mock.ts +169 -0
- package/src/server/__test-helpers/setup-embedded-flow.ts +30 -0
- package/src/server/__test-helpers/setup-fetch-flow.ts +29 -0
- package/src/server/__test-helpers/setup-non-embedded-flow.ts +31 -0
- package/src/server/__test-helpers/setup-valid-request.ts +78 -0
- package/src/server/__test-helpers/setup-valid-session.ts +28 -0
- package/src/server/__test-helpers/sign-request-cookie.ts +21 -0
- package/src/server/__test-helpers/test-config.ts +56 -0
- package/src/server/adapters/node/index.ts +13 -0
- package/src/server/adapters/vercel/index.ts +13 -0
- package/src/server/authenticate/admin/authenticate.admin.doc.example.ts +34 -0
- package/src/server/authenticate/admin/authenticate.admin.doc.ts +51 -0
- package/src/server/authenticate/admin/authenticate.ts +232 -0
- package/src/server/authenticate/admin/billing/authenticate.admin.billing.doc.ts +29 -0
- package/src/server/authenticate/admin/billing/cancel.ts +38 -0
- package/src/server/authenticate/admin/billing/check.ts +41 -0
- package/src/server/authenticate/admin/billing/create-usage-record.ts +36 -0
- package/src/server/authenticate/admin/billing/helpers.ts +42 -0
- package/src/server/authenticate/admin/billing/index.ts +6 -0
- package/src/server/authenticate/admin/billing/request.ts +64 -0
- package/src/server/authenticate/admin/billing/require.ts +58 -0
- package/src/server/authenticate/admin/billing/types.ts +613 -0
- package/src/server/authenticate/admin/billing/update-usage-subscription-capped-amount.ts +55 -0
- package/src/server/authenticate/admin/helpers/begin-auth.ts +17 -0
- package/src/server/authenticate/admin/helpers/create-admin-api-context.ts +20 -0
- package/src/server/authenticate/admin/helpers/ensure-app-is-embedded-if-required.ts +18 -0
- package/src/server/authenticate/admin/helpers/ensure-session-token-search-param-if-required.ts +25 -0
- package/src/server/authenticate/admin/helpers/handle-client-error.ts +45 -0
- package/src/server/authenticate/admin/helpers/index.ts +14 -0
- package/src/server/authenticate/admin/helpers/redirect-to-auth-page.ts +28 -0
- package/src/server/authenticate/admin/helpers/redirect-to-bounce-page.ts +23 -0
- package/src/server/authenticate/admin/helpers/redirect-to-install-page.ts +53 -0
- package/src/server/authenticate/admin/helpers/redirect-to-shopify-or-app-root.ts +21 -0
- package/src/server/authenticate/admin/helpers/redirect-with-app-bridge-headers.ts +13 -0
- package/src/server/authenticate/admin/helpers/redirect-with-exitiframe.ts +28 -0
- package/src/server/authenticate/admin/helpers/redirect.ts +164 -0
- package/src/server/authenticate/admin/helpers/render-app-bridge.ts +46 -0
- package/src/server/authenticate/admin/helpers/trigger-after-auth-hook.ts +29 -0
- package/src/server/authenticate/admin/helpers/validate-redirect-url.ts +67 -0
- package/src/server/authenticate/admin/helpers/validate-shop-and-host-params.ts +46 -0
- package/src/server/authenticate/admin/scope/authenticate.admin.scopes.doc.ts +28 -0
- package/src/server/authenticate/admin/scope/client/fetch-scopes-details.ts +43 -0
- package/src/server/authenticate/admin/scope/client/revoke-scopes.ts +38 -0
- package/src/server/authenticate/admin/scope/factory.ts +21 -0
- package/src/server/authenticate/admin/scope/query.ts +51 -0
- package/src/server/authenticate/admin/scope/request.ts +32 -0
- package/src/server/authenticate/admin/scope/revoke.ts +48 -0
- package/src/server/authenticate/admin/scope/types.ts +126 -0
- package/src/server/authenticate/admin/strategies/auth-code-flow.ts +332 -0
- package/src/server/authenticate/admin/strategies/merchant-custom-app.ts +65 -0
- package/src/server/authenticate/admin/strategies/token-exchange.ts +188 -0
- package/src/server/authenticate/admin/strategies/types.ts +29 -0
- package/src/server/authenticate/admin/types.ts +216 -0
- package/src/server/authenticate/const.ts +15 -0
- package/src/server/authenticate/flow/authenticate.flow.doc.example.ts +39 -0
- package/src/server/authenticate/flow/authenticate.flow.doc.ts +47 -0
- package/src/server/authenticate/flow/authenticate.ts +68 -0
- package/src/server/authenticate/flow/types.ts +85 -0
- package/src/server/authenticate/fulfillment-service/authenticate.fulfillment-service.doc.example.ts +52 -0
- package/src/server/authenticate/fulfillment-service/authenticate.fulfillment-service.doc.ts +48 -0
- package/src/server/authenticate/fulfillment-service/authenticate.ts +82 -0
- package/src/server/authenticate/fulfillment-service/types.ts +82 -0
- package/src/server/authenticate/helpers/add-response-headers.ts +43 -0
- package/src/server/authenticate/helpers/app-bridge-url.ts +10 -0
- package/src/server/authenticate/helpers/ensure-cors-headers.ts +38 -0
- package/src/server/authenticate/helpers/get-session-token-header.ts +11 -0
- package/src/server/authenticate/helpers/get-shop-from-request.ts +4 -0
- package/src/server/authenticate/helpers/idempotent-promise-handler.ts +45 -0
- package/src/server/authenticate/helpers/index.ts +10 -0
- package/src/server/authenticate/helpers/invalidate-access-token.ts +17 -0
- package/src/server/authenticate/helpers/reject-bot-request.ts +27 -0
- package/src/server/authenticate/helpers/respond-to-invalid-session-token.ts +28 -0
- package/src/server/authenticate/helpers/respond-to-options-request.ts +26 -0
- package/src/server/authenticate/helpers/validate-session-token.ts +40 -0
- package/src/server/authenticate/login/login.ts +53 -0
- package/src/server/authenticate/public/appProxy/authenticate.public.app-proxy.doc.example.ts +27 -0
- package/src/server/authenticate/public/appProxy/authenticate.public.app-proxy.doc.ts +55 -0
- package/src/server/authenticate/public/appProxy/authenticate.ts +150 -0
- package/src/server/authenticate/public/appProxy/types.ts +215 -0
- package/src/server/authenticate/public/checkout/authenticate.public.checkout.doc.example.offers.ts +21 -0
- package/src/server/authenticate/public/checkout/authenticate.public.checkout.doc.example.ts +17 -0
- package/src/server/authenticate/public/checkout/authenticate.public.checkout.doc.ts +49 -0
- package/src/server/authenticate/public/checkout/authenticate.ts +13 -0
- package/src/server/authenticate/public/checkout/types.ts +65 -0
- package/src/server/authenticate/public/customer-account/authenticate.public.customerAccount.doc.example.offers.ts +36 -0
- package/src/server/authenticate/public/customer-account/authenticate.public.customerAccount.doc.example.ts +18 -0
- package/src/server/authenticate/public/customer-account/authenticate.public.customerAccount.doc.ts +49 -0
- package/src/server/authenticate/public/customer-account/authenticate.ts +13 -0
- package/src/server/authenticate/public/customer-account/types.ts +65 -0
- package/src/server/authenticate/public/extension/authenticate.ts +54 -0
- package/src/server/authenticate/public/extension/types.ts +11 -0
- package/src/server/authenticate/public/factory.ts +24 -0
- package/src/server/authenticate/public/index.ts +1 -0
- package/src/server/authenticate/public/pos/authenticate.ts +8 -0
- package/src/server/authenticate/public/pos/types.ts +65 -0
- package/src/server/authenticate/public/types.ts +96 -0
- package/src/server/authenticate/webhooks/authenticate.ts +105 -0
- package/src/server/authenticate/webhooks/authenticate.webhooks.doc.example.ts +43 -0
- package/src/server/authenticate/webhooks/authenticate.webhooks.doc.ts +39 -0
- package/src/server/authenticate/webhooks/index.ts +1 -0
- package/src/server/authenticate/webhooks/register.ts +48 -0
- package/src/server/authenticate/webhooks/types.ts +266 -0
- package/src/server/boundary/error.tsx +14 -0
- package/src/server/boundary/headers.ts +15 -0
- package/src/server/boundary/index.ts +39 -0
- package/src/server/boundary/types.ts +4 -0
- package/src/server/clients/admin/authenticate.admin.api.doc.ts +34 -0
- package/src/server/clients/admin/factory.ts +22 -0
- package/src/server/clients/admin/graphql.ts +39 -0
- package/src/server/clients/admin/index.ts +2 -0
- package/src/server/clients/admin/types.ts +120 -0
- package/src/server/clients/index.ts +2 -0
- package/src/server/clients/storefront/authenticate.storefront.api.doc.ts +34 -0
- package/src/server/clients/storefront/factory.ts +32 -0
- package/src/server/clients/storefront/index.ts +2 -0
- package/src/server/clients/storefront/types.ts +78 -0
- package/src/server/clients/types.ts +47 -0
- package/src/server/config-types.ts +309 -0
- package/src/server/errors.ts +3 -0
- package/src/server/future/flags.ts +62 -0
- package/src/server/helpers/create-or-load-offline-session.ts +19 -0
- package/src/server/helpers/ensure-offline-token-is-not-expired.ts +32 -0
- package/src/server/helpers/ensure-valid-offline-session.ts +15 -0
- package/src/server/helpers/index.ts +4 -0
- package/src/server/helpers/refresh-token.ts +35 -0
- package/src/server/index.ts +24 -0
- package/src/server/override-logger.ts +42 -0
- package/src/server/shopify-app.doc.ts +45 -0
- package/src/server/shopify-app.ts +215 -0
- package/src/server/test-helpers/index.ts +1 -0
- package/src/server/test-helpers/test-config.ts +79 -0
- package/src/server/types-contexts.ts +44 -0
- package/src/server/types.ts +507 -0
- package/src/server/unauthenticated/admin/factory.ts +23 -0
- package/src/server/unauthenticated/admin/index.ts +1 -0
- package/src/server/unauthenticated/admin/types.ts +79 -0
- package/src/server/unauthenticated/admin/unauthenticated.admin.doc.ts +40 -0
- package/src/server/unauthenticated/storefront/factory.ts +28 -0
- package/src/server/unauthenticated/storefront/index.ts +1 -0
- package/src/server/unauthenticated/storefront/types.ts +105 -0
- package/src/server/unauthenticated/storefront/unauthenticated.storefront.doc.ts +40 -0
- package/src/server/unauthenticated/types.ts +65 -0
- package/src/server/version.ts +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {BasicParams} from '../../../types';
|
|
2
|
+
|
|
3
|
+
import {redirectToShopifyOrAppRoot} from './redirect-to-shopify-or-app-root';
|
|
4
|
+
|
|
5
|
+
export const ensureAppIsEmbeddedIfRequired = async (
|
|
6
|
+
params: BasicParams,
|
|
7
|
+
request: Request,
|
|
8
|
+
) => {
|
|
9
|
+
const {api, logger, config} = params;
|
|
10
|
+
const url = new URL(request.url);
|
|
11
|
+
|
|
12
|
+
const shop = url.searchParams.get('shop')!;
|
|
13
|
+
|
|
14
|
+
if (api.config.isEmbeddedApp && url.searchParams.get('embedded') !== '1') {
|
|
15
|
+
logger.debug('App is not embedded, redirecting to Shopify', {shop});
|
|
16
|
+
await redirectToShopifyOrAppRoot(request, {api, logger, config});
|
|
17
|
+
}
|
|
18
|
+
};
|
package/src/server/authenticate/admin/helpers/ensure-session-token-search-param-if-required.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {BasicParams} from '../../../types';
|
|
2
|
+
|
|
3
|
+
import {redirectToBouncePage} from './redirect-to-bounce-page';
|
|
4
|
+
|
|
5
|
+
const SESSION_TOKEN_PARAM = 'id_token';
|
|
6
|
+
|
|
7
|
+
export const ensureSessionTokenSearchParamIfRequired = async (
|
|
8
|
+
params: BasicParams,
|
|
9
|
+
request: Request,
|
|
10
|
+
) => {
|
|
11
|
+
const {api, logger} = params;
|
|
12
|
+
const url = new URL(request.url);
|
|
13
|
+
|
|
14
|
+
const shop = url.searchParams.get('shop')!;
|
|
15
|
+
const searchParamSessionToken = url.searchParams.get(SESSION_TOKEN_PARAM);
|
|
16
|
+
const isEmbedded = url.searchParams.get('embedded') === '1';
|
|
17
|
+
|
|
18
|
+
if (api.config.isEmbeddedApp && isEmbedded && !searchParamSessionToken) {
|
|
19
|
+
logger.debug(
|
|
20
|
+
'Missing session token in search params, going to bounce page',
|
|
21
|
+
{shop},
|
|
22
|
+
);
|
|
23
|
+
redirectToBouncePage(params, url);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {HttpResponseError} from '@shopify/shopify-api';
|
|
2
|
+
|
|
3
|
+
import type {HandleAdminClientError} from '../../../clients/admin/types';
|
|
4
|
+
import {HandleClientErrorOptions} from '../strategies/types';
|
|
5
|
+
|
|
6
|
+
export function handleClientErrorFactory({
|
|
7
|
+
request,
|
|
8
|
+
onError,
|
|
9
|
+
}: HandleClientErrorOptions): HandleAdminClientError {
|
|
10
|
+
return async function handleClientError({
|
|
11
|
+
error,
|
|
12
|
+
params,
|
|
13
|
+
session,
|
|
14
|
+
}): Promise<never> {
|
|
15
|
+
if (error instanceof HttpResponseError !== true) {
|
|
16
|
+
params.logger.debug(
|
|
17
|
+
`Got a response error from the API: ${error.message}`,
|
|
18
|
+
{shop: session.shop},
|
|
19
|
+
);
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
params.logger.debug(
|
|
24
|
+
`Got an HTTP response error from the API: ${error.message}`,
|
|
25
|
+
{
|
|
26
|
+
shop: session.shop,
|
|
27
|
+
code: error.response.code,
|
|
28
|
+
statusText: error.response.statusText,
|
|
29
|
+
body: JSON.stringify(error.response.body),
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if (onError) {
|
|
34
|
+
await onError({request, session, error});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// forward a minimal copy of the upstream HTTP response instead of an Error:
|
|
38
|
+
throw new Response(JSON.stringify(error.response.body), {
|
|
39
|
+
status: error.response.code,
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': error.response.headers!['Content-Type'] as string,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from './begin-auth';
|
|
2
|
+
export * from './create-admin-api-context';
|
|
3
|
+
export * from './ensure-app-is-embedded-if-required';
|
|
4
|
+
export * from './ensure-session-token-search-param-if-required';
|
|
5
|
+
export * from './handle-client-error';
|
|
6
|
+
export * from './redirect-to-auth-page';
|
|
7
|
+
export * from './redirect-to-bounce-page';
|
|
8
|
+
export * from './redirect-to-shopify-or-app-root';
|
|
9
|
+
export * from './redirect-with-app-bridge-headers';
|
|
10
|
+
export * from './redirect-with-exitiframe';
|
|
11
|
+
export * from './redirect';
|
|
12
|
+
export * from './render-app-bridge';
|
|
13
|
+
export * from './trigger-after-auth-hook';
|
|
14
|
+
export * from './validate-shop-and-host-params';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type {BasicParams} from '../../../types';
|
|
2
|
+
|
|
3
|
+
import {beginAuth} from './begin-auth';
|
|
4
|
+
import {redirectWithExitIframe} from './redirect-with-exitiframe';
|
|
5
|
+
import {redirectWithAppBridgeHeaders} from './redirect-with-app-bridge-headers';
|
|
6
|
+
|
|
7
|
+
export async function redirectToAuthPage(
|
|
8
|
+
params: BasicParams,
|
|
9
|
+
request: Request,
|
|
10
|
+
shop: string,
|
|
11
|
+
isOnline = false,
|
|
12
|
+
): Promise<never> {
|
|
13
|
+
const {config} = params;
|
|
14
|
+
|
|
15
|
+
const url = new URL(request.url);
|
|
16
|
+
const isEmbeddedRequest = url.searchParams.get('embedded') === '1';
|
|
17
|
+
const isXhrRequest = request.headers.get('authorization');
|
|
18
|
+
|
|
19
|
+
if (isXhrRequest) {
|
|
20
|
+
const redirectUri = new URL(config.auth.path, config.appUrl);
|
|
21
|
+
redirectUri.searchParams.set('shop', shop);
|
|
22
|
+
redirectWithAppBridgeHeaders(redirectUri.toString());
|
|
23
|
+
} else if (isEmbeddedRequest) {
|
|
24
|
+
redirectWithExitIframe(params, request, shop);
|
|
25
|
+
} else {
|
|
26
|
+
throw await beginAuth(params, request, isOnline, shop);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {redirect} from '@remix-run/server-runtime';
|
|
2
|
+
|
|
3
|
+
import {BasicParams} from '../../../types';
|
|
4
|
+
|
|
5
|
+
export const redirectToBouncePage = (params: BasicParams, url: URL): never => {
|
|
6
|
+
const {config} = params;
|
|
7
|
+
|
|
8
|
+
// Make sure we always point to the configured app URL so it also works behind reverse proxies (that alter the Host
|
|
9
|
+
// header).
|
|
10
|
+
const searchParams = url.searchParams;
|
|
11
|
+
searchParams.delete('id_token');
|
|
12
|
+
searchParams.set(
|
|
13
|
+
'shopify-reload',
|
|
14
|
+
`${config.appUrl}${url.pathname}?${searchParams.toString()}`,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
// eslint-disable-next-line no-warning-comments
|
|
18
|
+
// TODO Make sure this works on chrome without a tunnel (weird HTTPS redirect issue)
|
|
19
|
+
// https://github.com/orgs/Shopify/projects/6899/views/1?pane=issue&itemId=28376650
|
|
20
|
+
throw redirect(
|
|
21
|
+
`${config.auth.patchSessionTokenPath}?${searchParams.toString()}`,
|
|
22
|
+
);
|
|
23
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {redirect as remixRedirect} from '@remix-run/server-runtime';
|
|
2
|
+
|
|
3
|
+
import type {BasicParams} from '../../../types';
|
|
4
|
+
|
|
5
|
+
import {redirectWithAppBridgeHeaders} from './redirect-with-app-bridge-headers';
|
|
6
|
+
|
|
7
|
+
export async function redirectToInstallPage(
|
|
8
|
+
params: BasicParams,
|
|
9
|
+
shop: string,
|
|
10
|
+
optionalScopes: string[] = [],
|
|
11
|
+
): Promise<never> {
|
|
12
|
+
const installUrl = buildInstallUrl(params, shop, optionalScopes);
|
|
13
|
+
if (params.config.isEmbeddedApp) {
|
|
14
|
+
throw redirectWithAppBridgeHeaders(installUrl);
|
|
15
|
+
} else {
|
|
16
|
+
throw remixRedirect(installUrl);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildInstallUrl(
|
|
21
|
+
params: BasicParams,
|
|
22
|
+
shop: string,
|
|
23
|
+
optionalScopes: string[] = [],
|
|
24
|
+
) {
|
|
25
|
+
const baseInstallUrl = buildBaseInstallUrl(params, shop);
|
|
26
|
+
baseInstallUrl.search = buildParamsInstallUrl(
|
|
27
|
+
params,
|
|
28
|
+
optionalScopes,
|
|
29
|
+
).toString();
|
|
30
|
+
return baseInstallUrl.href;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildBaseInstallUrl({api}: BasicParams, shop: string) {
|
|
34
|
+
const cleanShop = api.utils.sanitizeShop(shop, true);
|
|
35
|
+
return new URL(`https://${cleanShop}/admin/oauth/install`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildParamsInstallUrl(
|
|
39
|
+
{config}: BasicParams,
|
|
40
|
+
optionalScopes: string[] = [],
|
|
41
|
+
) {
|
|
42
|
+
const optionalScopesParam =
|
|
43
|
+
optionalScopes && optionalScopes.length > 0
|
|
44
|
+
? {optional_scopes: optionalScopes.join(',')}
|
|
45
|
+
: undefined;
|
|
46
|
+
|
|
47
|
+
const query = {
|
|
48
|
+
client_id: config.apiKey,
|
|
49
|
+
scope: config.scopes?.toString() || '',
|
|
50
|
+
...optionalScopesParam,
|
|
51
|
+
};
|
|
52
|
+
return new URLSearchParams(query);
|
|
53
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {redirect} from '@remix-run/server-runtime';
|
|
2
|
+
|
|
3
|
+
import type {BasicParams} from '../../../types';
|
|
4
|
+
|
|
5
|
+
export async function redirectToShopifyOrAppRoot(
|
|
6
|
+
request: Request,
|
|
7
|
+
params: BasicParams,
|
|
8
|
+
responseHeaders?: Headers,
|
|
9
|
+
): Promise<never> {
|
|
10
|
+
const {api} = params;
|
|
11
|
+
const url = new URL(request.url);
|
|
12
|
+
|
|
13
|
+
const host = api.utils.sanitizeHost(url.searchParams.get('host')!)!;
|
|
14
|
+
const shop = api.utils.sanitizeShop(url.searchParams.get('shop')!)!;
|
|
15
|
+
|
|
16
|
+
const redirectUrl = api.config.isEmbeddedApp
|
|
17
|
+
? await api.auth.getEmbeddedAppUrl({rawRequest: request})
|
|
18
|
+
: `/?shop=${shop}&host=${encodeURIComponent(host)}`;
|
|
19
|
+
|
|
20
|
+
throw redirect(redirectUrl, {headers: responseHeaders});
|
|
21
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {REAUTH_URL_HEADER} from '../../const';
|
|
2
|
+
|
|
3
|
+
export function redirectWithAppBridgeHeaders(redirectUri: string): never {
|
|
4
|
+
throw new Response(undefined, {
|
|
5
|
+
status: 401,
|
|
6
|
+
statusText: 'Unauthorized',
|
|
7
|
+
headers: getAppBridgeHeaders(redirectUri),
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getAppBridgeHeaders(url: string) {
|
|
12
|
+
return new Headers({[REAUTH_URL_HEADER]: url});
|
|
13
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {redirect} from '@remix-run/server-runtime';
|
|
2
|
+
|
|
3
|
+
import type {BasicParams} from '../../../types';
|
|
4
|
+
|
|
5
|
+
export function redirectWithExitIframe(
|
|
6
|
+
params: BasicParams,
|
|
7
|
+
request: Request,
|
|
8
|
+
shop: string,
|
|
9
|
+
): never {
|
|
10
|
+
const {api, config} = params;
|
|
11
|
+
const url = new URL(request.url);
|
|
12
|
+
|
|
13
|
+
const queryParams = url.searchParams;
|
|
14
|
+
|
|
15
|
+
const host = api.utils.sanitizeHost(queryParams.get('host')!);
|
|
16
|
+
|
|
17
|
+
queryParams.set('shop', shop);
|
|
18
|
+
|
|
19
|
+
let destination = `${config.auth.path}?shop=${shop}`;
|
|
20
|
+
|
|
21
|
+
if (host) {
|
|
22
|
+
queryParams.set('host', host);
|
|
23
|
+
destination = `${destination}&host=${host}`;
|
|
24
|
+
}
|
|
25
|
+
queryParams.set('exitIframe', destination);
|
|
26
|
+
|
|
27
|
+
throw redirect(`${config.auth.exitIframePath}?${queryParams.toString()}`);
|
|
28
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TypedResponse,
|
|
3
|
+
redirect as remixRedirect,
|
|
4
|
+
} from '@remix-run/server-runtime';
|
|
5
|
+
|
|
6
|
+
import {BasicParams} from '../../../types';
|
|
7
|
+
import {getSessionTokenHeader} from '../../helpers/get-session-token-header';
|
|
8
|
+
|
|
9
|
+
import {renderAppBridge} from './render-app-bridge';
|
|
10
|
+
import {redirectWithAppBridgeHeaders} from './redirect-with-app-bridge-headers';
|
|
11
|
+
|
|
12
|
+
export type RedirectTarget = '_self' | '_parent' | '_top' | '_blank';
|
|
13
|
+
export type RedirectInit = number | (ResponseInit & {target?: RedirectTarget});
|
|
14
|
+
export type RedirectFunction = (
|
|
15
|
+
url: string,
|
|
16
|
+
init?: RedirectInit,
|
|
17
|
+
) => TypedResponse<never>;
|
|
18
|
+
|
|
19
|
+
interface ParseURLOptions {
|
|
20
|
+
params: BasicParams;
|
|
21
|
+
url: string;
|
|
22
|
+
base: string;
|
|
23
|
+
shop: string;
|
|
24
|
+
init: RedirectInit;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ParsedURL {
|
|
28
|
+
url: URL;
|
|
29
|
+
target: RedirectTarget;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function redirectFactory(
|
|
33
|
+
params: BasicParams,
|
|
34
|
+
request: Request,
|
|
35
|
+
shop: string,
|
|
36
|
+
): RedirectFunction {
|
|
37
|
+
const {config, logger} = params;
|
|
38
|
+
|
|
39
|
+
return function redirect(url, init: RedirectInit) {
|
|
40
|
+
const {searchParams} = new URL(request.url);
|
|
41
|
+
const {url: parsedUrl, target} = parseURL({
|
|
42
|
+
params,
|
|
43
|
+
url,
|
|
44
|
+
base: config.appUrl,
|
|
45
|
+
shop,
|
|
46
|
+
init,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
logger.debug('Redirecting', {shop, url: parsedUrl.toString()});
|
|
50
|
+
|
|
51
|
+
const isSameOrigin = parsedUrl.origin === config.appUrl;
|
|
52
|
+
if (isSameOrigin || url.startsWith('/')) {
|
|
53
|
+
searchParams.forEach((value, key) => {
|
|
54
|
+
if (!parsedUrl.searchParams.has(key)) {
|
|
55
|
+
parsedUrl.searchParams.set(key, value);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (target === '_self') {
|
|
61
|
+
if (isBounceRequest(request)) {
|
|
62
|
+
throw renderAppBridge(params, request, {
|
|
63
|
+
url: parsedUrl.toString(),
|
|
64
|
+
target,
|
|
65
|
+
});
|
|
66
|
+
} else {
|
|
67
|
+
return remixRedirect(parsedUrl.toString(), init);
|
|
68
|
+
}
|
|
69
|
+
} else if (isDataRequest(request)) {
|
|
70
|
+
throw redirectWithAppBridgeHeaders(parsedUrl.toString());
|
|
71
|
+
} else if (isEmbeddedRequest(request)) {
|
|
72
|
+
throw renderAppBridge(params, request, {
|
|
73
|
+
url: parsedUrl.toString(),
|
|
74
|
+
target,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return remixRedirect(url, init);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isBounceRequest(request: Request) {
|
|
82
|
+
return (
|
|
83
|
+
Boolean(getSessionTokenHeader(request)) &&
|
|
84
|
+
request.headers.has('X-Shopify-Bounce')
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isDataRequest(request: Request) {
|
|
89
|
+
const isGet = request.method === 'GET';
|
|
90
|
+
const sessionTokenHeader = Boolean(getSessionTokenHeader(request));
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
sessionTokenHeader &&
|
|
94
|
+
!isBounceRequest(request) &&
|
|
95
|
+
(!isEmbeddedRequest(request) || !isGet)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isEmbeddedRequest(request: Request) {
|
|
100
|
+
const {searchParams} = new URL(request.url);
|
|
101
|
+
|
|
102
|
+
return searchParams.get('embedded') === '1';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseURL({params, base, init, shop, url}: ParseURLOptions): ParsedURL {
|
|
106
|
+
let target: RedirectTarget | undefined =
|
|
107
|
+
typeof init !== 'number' && init?.target ? init.target : undefined;
|
|
108
|
+
|
|
109
|
+
if (isAdminRemotePath(url)) {
|
|
110
|
+
const {config} = params;
|
|
111
|
+
|
|
112
|
+
const adminPath = getAdminRemotePath(url);
|
|
113
|
+
const cleanShopName = shop.replace('.myshopify.com', '');
|
|
114
|
+
|
|
115
|
+
if (!target) {
|
|
116
|
+
target = config.isEmbeddedApp ? '_parent' : '_self';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
url: new URL(
|
|
121
|
+
`https://admin.shopify.com/store/${cleanShopName}${adminPath}`,
|
|
122
|
+
),
|
|
123
|
+
target,
|
|
124
|
+
};
|
|
125
|
+
} else {
|
|
126
|
+
return {
|
|
127
|
+
url: new URL(url, base),
|
|
128
|
+
target: target ?? '_self',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const ADMIN_REGEX = /^shopify:\/*admin\//i;
|
|
134
|
+
|
|
135
|
+
function isAdminRemotePath(url: string) {
|
|
136
|
+
return ADMIN_REGEX.test(url);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getAdminRemotePath(url: string | URL) {
|
|
140
|
+
const parsedUrl = removeRestrictedParams(new URL(url)).href;
|
|
141
|
+
return parsedUrl.replace(ADMIN_REGEX, '/');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const embeddedFrameParamsToRemove = [
|
|
145
|
+
'hmac',
|
|
146
|
+
'locale',
|
|
147
|
+
'protocol',
|
|
148
|
+
'session',
|
|
149
|
+
'id_token',
|
|
150
|
+
'shop',
|
|
151
|
+
'timestamp',
|
|
152
|
+
'host',
|
|
153
|
+
'embedded',
|
|
154
|
+
// sent when clicking rel="home" nav item
|
|
155
|
+
'appLoadId',
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
function removeRestrictedParams(url: URL | string) {
|
|
159
|
+
const newUrl = new URL(url);
|
|
160
|
+
embeddedFrameParamsToRemove.forEach((param) =>
|
|
161
|
+
newUrl.searchParams.delete(param),
|
|
162
|
+
);
|
|
163
|
+
return newUrl;
|
|
164
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {BasicParams} from '../../../types';
|
|
2
|
+
import {appBridgeUrl} from '../../helpers/app-bridge-url';
|
|
3
|
+
import {addDocumentResponseHeaders} from '../../helpers/add-response-headers';
|
|
4
|
+
|
|
5
|
+
import {sanitizeRedirectUrl} from './validate-redirect-url';
|
|
6
|
+
|
|
7
|
+
import type {RedirectTarget} from '.';
|
|
8
|
+
|
|
9
|
+
export interface RedirectToOptions {
|
|
10
|
+
url: string | URL;
|
|
11
|
+
target?: RedirectTarget;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function renderAppBridge(
|
|
15
|
+
{config}: BasicParams,
|
|
16
|
+
request: Request,
|
|
17
|
+
redirectTo?: RedirectToOptions,
|
|
18
|
+
): never {
|
|
19
|
+
let redirectToScript = '';
|
|
20
|
+
if (redirectTo) {
|
|
21
|
+
const destination = sanitizeRedirectUrl(config.appUrl, redirectTo.url);
|
|
22
|
+
|
|
23
|
+
const target = redirectTo.target ?? '_top';
|
|
24
|
+
|
|
25
|
+
redirectToScript = `<script>window.open(${JSON.stringify(
|
|
26
|
+
destination.toString(),
|
|
27
|
+
)}, ${JSON.stringify(target)})</script>`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const responseHeaders = new Headers({
|
|
31
|
+
'content-type': 'text/html;charset=utf-8',
|
|
32
|
+
});
|
|
33
|
+
addDocumentResponseHeaders(
|
|
34
|
+
responseHeaders,
|
|
35
|
+
config.isEmbeddedApp,
|
|
36
|
+
new URL(request.url).searchParams.get('shop'),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
throw new Response(
|
|
40
|
+
`
|
|
41
|
+
<script data-api-key="${config.apiKey}" src="${appBridgeUrl()}"></script>
|
|
42
|
+
${redirectToScript}
|
|
43
|
+
`,
|
|
44
|
+
{headers: responseHeaders},
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {Session} from '@shopify/shopify-api';
|
|
2
|
+
|
|
3
|
+
import type {BasicParams} from '../../../types';
|
|
4
|
+
import {AuthorizationStrategy} from '../strategies/types';
|
|
5
|
+
|
|
6
|
+
import {createAdminApiContext} from './create-admin-api-context';
|
|
7
|
+
|
|
8
|
+
export async function triggerAfterAuthHook(
|
|
9
|
+
params: BasicParams,
|
|
10
|
+
session: Session,
|
|
11
|
+
request: Request,
|
|
12
|
+
authStrategy: AuthorizationStrategy,
|
|
13
|
+
) {
|
|
14
|
+
const {config, logger} = params;
|
|
15
|
+
if (config.hooks.afterAuth) {
|
|
16
|
+
logger.info('Running afterAuth hook', {shop: session.shop});
|
|
17
|
+
|
|
18
|
+
const admin = createAdminApiContext(
|
|
19
|
+
session,
|
|
20
|
+
params,
|
|
21
|
+
authStrategy.handleClientError(request),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
await config.hooks.afterAuth({
|
|
25
|
+
session,
|
|
26
|
+
admin,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {ShopifyError} from '@shopify/shopify-api';
|
|
2
|
+
|
|
3
|
+
interface Options {
|
|
4
|
+
requireSSL?: boolean;
|
|
5
|
+
throwOnInvalid?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type SanitizedRedirectUrl<OptionsArg extends Options> =
|
|
9
|
+
OptionsArg['throwOnInvalid'] extends false ? URL | undefined : URL;
|
|
10
|
+
|
|
11
|
+
const FILE_URI_MATCH = /\/\/\//;
|
|
12
|
+
const INVALID_RELATIVE_URL = /[/\\][/\\]/;
|
|
13
|
+
const WHITESPACE_CHARACTER = /\s/;
|
|
14
|
+
const VALID_PROTOCOLS = ['https:', 'http:'];
|
|
15
|
+
|
|
16
|
+
function isSafe(
|
|
17
|
+
domain: string,
|
|
18
|
+
redirectUrl: unknown,
|
|
19
|
+
requireSSL: boolean | undefined = true,
|
|
20
|
+
): redirectUrl is string {
|
|
21
|
+
if (typeof redirectUrl !== 'string') {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (
|
|
26
|
+
FILE_URI_MATCH.test(redirectUrl) ||
|
|
27
|
+
WHITESPACE_CHARACTER.test(redirectUrl)
|
|
28
|
+
) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let url: URL;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
url = new URL(redirectUrl, domain);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (INVALID_RELATIVE_URL.test(url.pathname)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!VALID_PROTOCOLS.includes(url.protocol)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (requireSSL && url.protocol !== 'https:') {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function sanitizeRedirectUrl<OptionsArg extends Options>(
|
|
56
|
+
domain: string,
|
|
57
|
+
redirectUrl: unknown,
|
|
58
|
+
options: OptionsArg = {} as OptionsArg,
|
|
59
|
+
): SanitizedRedirectUrl<OptionsArg> {
|
|
60
|
+
if (isSafe(domain, redirectUrl, options.requireSSL)) {
|
|
61
|
+
return new URL(redirectUrl, domain) as SanitizedRedirectUrl<OptionsArg>;
|
|
62
|
+
} else if (options.throwOnInvalid === false) {
|
|
63
|
+
return undefined as SanitizedRedirectUrl<OptionsArg>;
|
|
64
|
+
} else {
|
|
65
|
+
throw new ShopifyError('Invalid URL. Refusing to redirect');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {redirect} from '@remix-run/server-runtime';
|
|
2
|
+
|
|
3
|
+
import {BasicParams} from '../../../types';
|
|
4
|
+
|
|
5
|
+
export function validateShopAndHostParams(
|
|
6
|
+
params: BasicParams,
|
|
7
|
+
request: Request,
|
|
8
|
+
) {
|
|
9
|
+
const {api, config, logger} = params;
|
|
10
|
+
|
|
11
|
+
if (config.isEmbeddedApp) {
|
|
12
|
+
const url = new URL(request.url);
|
|
13
|
+
const shop = api.utils.sanitizeShop(url.searchParams.get('shop')!);
|
|
14
|
+
if (!shop) {
|
|
15
|
+
logger.debug('Missing or invalid shop, redirecting to login path', {
|
|
16
|
+
shop,
|
|
17
|
+
});
|
|
18
|
+
throw redirectToLoginPath(request, params);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const host = api.utils.sanitizeHost(url.searchParams.get('host')!);
|
|
22
|
+
if (!host) {
|
|
23
|
+
logger.debug('Invalid host, redirecting to login path', {
|
|
24
|
+
shop,
|
|
25
|
+
host: url.searchParams.get('host'),
|
|
26
|
+
});
|
|
27
|
+
throw redirectToLoginPath(request, params);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function redirectToLoginPath(request: Request, params: BasicParams): never {
|
|
33
|
+
const {config, logger} = params;
|
|
34
|
+
|
|
35
|
+
const {pathname} = new URL(request.url);
|
|
36
|
+
if (pathname === config.auth.loginPath) {
|
|
37
|
+
const message =
|
|
38
|
+
`Detected call to shopify.authenticate.admin() from configured login path ` +
|
|
39
|
+
`('${config.auth.loginPath}'), please make sure to call shopify.login() from that route instead.`;
|
|
40
|
+
|
|
41
|
+
logger.debug(message);
|
|
42
|
+
throw new Response(message, {status: 500});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
throw redirect(config.auth.loginPath);
|
|
46
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs';
|
|
2
|
+
|
|
3
|
+
const data: ReferenceEntityTemplateSchema = {
|
|
4
|
+
name: 'Scopes',
|
|
5
|
+
description:
|
|
6
|
+
'Contains functions used to manage scopes for your app.\n\nThis object is returned on authenticated Admin requests.',
|
|
7
|
+
category: 'APIs',
|
|
8
|
+
type: 'object',
|
|
9
|
+
isVisualComponent: false,
|
|
10
|
+
definitions: [
|
|
11
|
+
{
|
|
12
|
+
title: 'scopes',
|
|
13
|
+
description:
|
|
14
|
+
'Provides utilities that apps can use to [manage scopes](https://shopify.dev/docs/apps/build/authentication-authorization/app-installation/manage-access-scopes) for the app using the Admin API.',
|
|
15
|
+
type: 'ScopesApiContext',
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
jsDocTypeExamples: ['ScopesApiContext'],
|
|
19
|
+
related: [
|
|
20
|
+
{
|
|
21
|
+
name: 'Admin context',
|
|
22
|
+
subtitle: 'Authenticate requests from Shopify Admin.',
|
|
23
|
+
url: '/docs/api/shopify-app-remix/authenticate/admin',
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default data;
|