Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

automate hashes.json update #2331

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
name: PR

on:
push:
paths:
- 'packages/checkout/widgets-lib/hashes.json'
pull_request:
branches:
- main
merge_group:
branches:
- main
workflow_dispatch:

permissions:
contents: write
pull-requests: write
repository-projects: write

env:
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.TS_IMMUTABLE_SDK_NX_TOKEN }}
Expand Down Expand Up @@ -44,6 +53,35 @@ jobs:
- name: Build, Lint, Test & Typecheck
run: yarn nx affected -t build,lint,test,typecheck

# Temporary disabled until we can automate this
- name: Validate Checkout Widgets Hashes
id: validate_checkout_widgets_hashes
zaidarain1 marked this conversation as resolved.
Show resolved Hide resolved
env:
GITHUB_TOKEN: ${{ secrets.TS_IMMUTABLE_SDK_GITHUB_TOKEN }}
run: |
cd packages/checkout/widgets-lib
yarn updateHashes
if [ -n "$(git diff --exit-code hashes.json)" ]; then
git add hashes.json
git commit -m "Update hashes.json"
git push
echo "HASH_UPDATED=true" >> $GITHUB_OUTPUT
fi

# - name: Retrigger workflow
# if: steps.validate_checkout_widgets_hashes.outputs.HASH_UPDATED == 'true'
# uses: actions/github-script@v6
# with:
# script: |
# const workflow = `${{ github.workflow }}`;
# const ref = `${{ github.event.pull_request.head.ref }}`;
# await github.rest.actions.createWorkflowDispatch({
# owner: context.repo.owner,
# repo: context.repo.repo,
# workflow_id: workflow,
# ref: ref
# });

build-lint-test-examples:
name: Build, Lint & Test Examples
runs-on: ubuntu-latest-8-cores
Expand Down
2 changes: 1 addition & 1 deletion packages/checkout/sdk/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ export class Checkout {
) {
const checkout = this;
try {
const cdnUrl = getWidgetsEsmUrl(validVersion);
const cdnUrl = await getWidgetsEsmUrl(validVersion);

// WebpackIgnore comment required to prevent webpack modifying the import statement and
// breaking the dynamic import in certain applications integrating checkout
Expand Down
34 changes: 34 additions & 0 deletions packages/checkout/sdk/src/widgets/hashUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/* eslint-disable max-len */
export async function generateSHA512Hash(url: string): Promise<string> {
// Fetch the content of the remote JavaScript file
const response = await fetch(url);
const content = await response.text();

// Convert the content to an ArrayBuffer
const encoder = new TextEncoder();
const data = encoder.encode(content);

// Use the Browser WebCrypto SubtleCrypto API to generate a SHA-512 hash
const hashBuffer = await window.crypto.subtle.digest('SHA-512', data);

// Convert the hash to a Base64 string
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashBase64 = btoa(String.fromCharCode(...hashArray));

return `sha512-${hashBase64}`;
}

async function getLatestGitTag(): Promise<string> {
const response = await fetch('https://api.github.com/repos/immutable/ts-immutable-sdk/tags');
const tags = await response.json();
return tags[0].name;
}

export async function validatedHashesUrl(version: string): Promise<string> {
if (version !== 'latest') {
return `https://raw.githubusercontent.com/immutable/ts-immutable-sdk/refs/tags/${version}/packages/checkout/widgets-lib/hashes.json`;
}

const latestGitTag = await getLatestGitTag();
return `https://raw.githubusercontent.com/immutable/ts-immutable-sdk/refs/tags/${latestGitTag}/packages/checkout/widgets-lib/hashes.json`;
}
36 changes: 28 additions & 8 deletions packages/checkout/sdk/src/widgets/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,55 @@
import { SDK_VERSION_MARKER } from '../env';
import { getWidgetsEsmUrl, loadUnresolvedBundle } from './load';

const SDK_VERSION = SDK_VERSION_MARKER;

jest.mock('./hashUtils', () => ({
generateSHA512Hash: jest.fn(async () => 'sha512-abc123'),
// eslint-disable-next-line max-len
validatedHashesUrl: jest.fn(async () => `https://raw.githubusercontent.com/immutable/ts-immutable-sdk/refs/tags/${SDK_VERSION}/packages/checkout/widgets-lib/hashes.json`),
}));

describe('load', () => {
const SDK_VERSION = SDK_VERSION_MARKER;
const scriptId = 'immutable-checkout-widgets-bundle';

beforeEach(() => {
jest.spyOn(console, 'warn').mockImplementation(() => { });
});

describe('load unresolved bundle', () => {
it('should validate the versioning', () => {
it('should validate the versioning', async () => {
const tag = document.createElement('script');
loadUnresolvedBundle(tag, scriptId, SDK_VERSION);
await loadUnresolvedBundle(tag, scriptId, SDK_VERSION);

expect(document.head.innerHTML).toBe(
'<script id="immutable-checkout-widgets-bundle" '
'<script '
+ 'integrity="sha512-abc123" '
+ 'crossorigin="anonymous" '
+ 'id="immutable-checkout-widgets-bundle" '
+ 'data-version="__SDK_VERSION__" '
+ `src="https://cdn.jsdelivr.net/npm/@imtbl/sdk@${SDK_VERSION}/dist/browser/checkout/widgets.js"></script>`,
);
});
});

describe('get widgets esm url', () => {
it('should validate the versioning', () => {
expect(getWidgetsEsmUrl(SDK_VERSION)).toEqual(
beforeEach(() => {
// @ts-expect-error mocking only json value of fetch response
global.fetch = jest.fn(async () => ({
json: async () => ({ 'dist/index.js': 'sha512-abc123' }),
}));
});

it('should validate the versioning', async () => {
const widgetsEsmUrl = await getWidgetsEsmUrl(SDK_VERSION);
expect(widgetsEsmUrl).toEqual(
`https://cdn.jsdelivr.net/npm/@imtbl/sdk@${SDK_VERSION}/dist/browser/checkout/widgets-esm.js`,
);
});

it('should change version', () => {
expect(getWidgetsEsmUrl('1.2.3')).toEqual(
it('should change version', async () => {
const widgetsEsmUrl = await getWidgetsEsmUrl('1.2.3');
expect(widgetsEsmUrl).toEqual(
'https://cdn.jsdelivr.net/npm/@imtbl/[email protected]/dist/browser/checkout/widgets-esm.js',
);
});
Expand Down
34 changes: 29 additions & 5 deletions packages/checkout/sdk/src/widgets/load.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useLocalBundle } from '../env';
import { generateSHA512Hash, validatedHashesUrl } from './hashUtils';

// Loads the checkout widgets bundle from the CDN and appends the script to the document head
export function loadUnresolvedBundle(
export async function loadUnresolvedBundle(
tag: HTMLScriptElement,
scriptId: string,
validVersion: string,
Expand All @@ -17,6 +18,12 @@ export function loadUnresolvedBundle(
let cdnUrl = `https://cdn.jsdelivr.net/npm/@imtbl/sdk@${validVersion}/dist/browser/checkout/widgets.js`;
if (useLocalBundle()) cdnUrl = `http://${window.location.host}/lib/js/widgets.js`;

if (!useLocalBundle()) {
const integrityHash = await generateSHA512Hash(cdnUrl);
tag.setAttribute('integrity', integrityHash);
tag.setAttribute('crossorigin', 'anonymous');
}

tag.setAttribute('id', scriptId);
tag.setAttribute('data-version', validVersion);
tag.setAttribute('src', cdnUrl);
Expand All @@ -25,10 +32,27 @@ export function loadUnresolvedBundle(
}

// Gets the CDN url for the split checkout widgets bundle
export function getWidgetsEsmUrl(
export async function getWidgetsEsmUrl(
validVersion: string,
): string {
let cdnUrl = `https://cdn.jsdelivr.net/npm/@imtbl/sdk@${validVersion}/dist/browser/checkout/widgets-esm.js`;
if (useLocalBundle()) cdnUrl = `http://${window.location.host}/lib/js/index.js`;
): Promise<Promise<string>> {
if (useLocalBundle()) return `http://${window.location.host}/lib/js/index.js`;

const cdnUrl = `https://cdn.jsdelivr.net/npm/@imtbl/sdk@${validVersion}/dist/browser/checkout/widgets-esm.js`;

const validHashesUrl = await validatedHashesUrl(validVersion);

const hash = await generateSHA512Hash(cdnUrl);

const widgetsEsmHash: string = await fetch(validHashesUrl)
.then((response) => response.json())
.then((hashes) => hashes['dist/index.js'])
.catch(() => {
throw new Error('Security Error: could not fetch widgets-esm.js hash');
});

if (hash !== widgetsEsmHash) {
throw new Error('Security Error: widgets-esm.js hash mismatch');
}

return cdnUrl;
}
2 changes: 1 addition & 1 deletion packages/checkout/widgets-lib/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module.exports = {
"extends": ["../../../.eslintrc"],
"ignorePatterns": ["jest.config.*", "rollup.config.*"],
"ignorePatterns": ["jest.config.*", "rollup.config.*", "*.js"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json",
Expand Down
4 changes: 4 additions & 0 deletions packages/checkout/widgets-lib/hashes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"dist/widgets.js": "sha512-3Nnn0FxOXI/zJdzL/ewukgth/qQSqhdDX4PfPS4RlF9kJlhaLKljJZEm/0HpYavAYoUArvCmA7FLrUiOpumiRg==",
"dist/index.js": "sha512-LV58EvEqcCXNUKjyyDexhQ64kDkXVf3fx724eBxNUHWMlOzRTuy3QjRHILVPAzKqf6iA4jY9wc9Wz0JbKPDs/w=="
}
5 changes: 3 additions & 2 deletions packages/checkout/widgets-lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,16 @@
"build": "yarn clean && NODE_ENV=production rollup --config rollup.config.js",
"build:analyse": "yarn build --plugin visualizer",
"build:local": "yarn clean && yarn build && mkdir -p ../widgets-sample-app/public/lib/js && cp dist/*.js ../widgets-sample-app/public/lib/js/",
"prepare:examplewidgets": "yarn workspace @examples/sdk-load-widgets-with-nextjs exec mkdir -p tests/utils/local-widgets-js/ && cp $(yarn workspace @imtbl/sdk exec pwd)/dist/browser/checkout/*.js $(yarn workspace @examples/sdk-load-widgets-with-nextjs exec pwd)/tests/utils/local-widgets-js/",
"clean": "rimraf ./dist",
"d": "rollup --config rollup.config.js",
"lint": "eslint ./src --ext .ts,.jsx,.tsx --max-warnings=0",
"lint:fix": "eslint ./src --ext .ts,.jsx,.tsx --max-warnings=0 --fix",
"prepare:examplewidgets": "yarn workspace @examples/sdk-load-widgets-with-nextjs exec mkdir -p tests/utils/local-widgets-js/ && cp $(yarn workspace @imtbl/sdk exec pwd)/dist/browser/checkout/*.js $(yarn workspace @examples/sdk-load-widgets-with-nextjs exec pwd)/tests/utils/local-widgets-js/",
"start": "yarn clean && NODE_ENV=development rollup --config rollup.config.js --watch",
"test": "jest test --passWithNoTests",
"test:watch": "jest test --passWithNoTests --watch",
"typecheck": "tsc --customConditions \"default\" --noEmit"
"typecheck": "tsc --customConditions \"default\" --noEmit",
"updateHashes": "yarn run --top-level nx run @imtbl/checkout-widgets:build && node ./updateHashes.js"
},
"type": "module",
"types": "./dist/index.d.ts"
Expand Down
2 changes: 2 additions & 0 deletions packages/checkout/widgets-lib/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from './factory';

export const bla = 'test';
24 changes: 24 additions & 0 deletions packages/checkout/widgets-lib/updateHashes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// @ts-check
import { readFileSync, writeFileSync } from "fs"
import { createHash } from "crypto"

const filesToHash = ["dist/widgets.js", "dist/index.js"]

filesToHash.forEach(file => {
try {
readFileSync(file)
} catch (e) {
console.error(`File ${file} not found`)
console.error('Please build the Checkout Widgets package')
process.exit(1)
}
})

const hashes = filesToHash.reduce((acc, file) => {
const hash = `sha512-${createHash("sha512").update(readFileSync(file
)).digest("base64")}`
acc[file] = hash
return acc
}, {})

writeFileSync("hashes.json", JSON.stringify(hashes, null, 2))