diff --git a/.changeset/dull-seas-remember.md b/.changeset/dull-seas-remember.md new file mode 100644 index 0000000000..b59002e6ba --- /dev/null +++ b/.changeset/dull-seas-remember.md @@ -0,0 +1,8 @@ +--- +'hive': minor +--- + +Add preflight scripts for laboratory. + +It is now possible to add a preflight script within the laboratory that executes before sending a GraphQL request. +[Learn more.](https://the-guild.dev/graphql/hive/product-updates/2024-12-27-preflight-script) diff --git a/.github/workflows/tests-e2e.yaml b/.github/workflows/tests-e2e.yaml index accb718f54..258c91166c 100644 --- a/.github/workflows/tests-e2e.yaml +++ b/.github/workflows/tests-e2e.yaml @@ -27,7 +27,7 @@ jobs: - name: setup environment uses: ./.github/actions/setup with: - codegen: false + codegen: true actor: test-e2e cacheTurbo: false @@ -43,7 +43,7 @@ jobs: timeout-minutes: 10 run: | docker compose \ - --env-file docker/.end2end.env \ + --env-file integration-tests/.env \ -f docker/docker-compose.community.yml \ -f docker/docker-compose.end2end.yml \ up -d --wait @@ -65,7 +65,7 @@ jobs: docker --version docker ps --format json | jq . docker compose \ - --env-file docker/.end2end.env \ + --env-file integration-tests/.env \ -f docker/docker-compose.community.yml \ -f docker/docker-compose.end2end.yml \ logs diff --git a/cypress.config.ts b/cypress.config.ts index 1d57cfaaeb..4bd27e952a 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,19 +1,38 @@ +import * as fs from 'node:fs'; // eslint-disable-next-line import/no-extraneous-dependencies -- cypress SHOULD be a dev dependency -import fs from 'node:fs'; import { defineConfig } from 'cypress'; +import { initSeed } from './integration-tests/testkit/seed'; + +if (!process.env.RUN_AGAINST_LOCAL_SERVICES) { + const dotenv = await import('dotenv'); + dotenv.config({ path: import.meta.dirname + '/integration-tests/.env' }); +} const isCI = Boolean(process.env.CI); +export const seed = initSeed(); + export default defineConfig({ video: isCI, screenshotOnRunFailure: isCI, defaultCommandTimeout: 15_000, // sometimes the app takes longer to load, especially in the CI retries: 2, - env: { - POSTGRES_URL: 'postgresql://postgres:postgres@localhost:5432/registry', - }, e2e: { setupNodeEvents(on) { + on('task', { + async seedTarget() { + const owner = await seed.createOwner(); + const org = await owner.createOrg(); + const project = await org.createProject(); + const slug = `${org.organization.slug}/${project.project.slug}/${project.target.slug}`; + return { + slug, + refreshToken: owner.ownerRefreshToken, + email: owner.ownerEmail, + }; + }, + }); + on('after:spec', (_, results) => { if (results && results.video) { // Do we have failures for any retry attempts? diff --git a/cypress/e2e/preflight-script.cy.ts b/cypress/e2e/preflight-script.cy.ts new file mode 100644 index 0000000000..0344be2c26 --- /dev/null +++ b/cypress/e2e/preflight-script.cy.ts @@ -0,0 +1,196 @@ +beforeEach(() => { + cy.clearLocalStorage().then(async () => { + cy.task('seedTarget').then(({ slug, refreshToken }: any) => { + cy.setCookie('sRefreshToken', refreshToken); + + cy.visit(`/${slug}/laboratory`); + cy.get('[aria-label*="Preflight Script"]').click(); + }); + }); +}); + +/** Helper function for setting the text within a monaco editor as typing manually results in flaky tests */ +function setMonacoEditorContents(editorCyName: string, text: string) { + // wait for textarea appearing which indicates monaco is loaded + cy.dataCy(editorCyName).find('textarea'); + cy.window().then(win => { + // First, check if monaco is available on the main window + const editor = (win as any).monaco.editor + .getEditors() + .find(e => e.getContainerDomNode().parentElement.getAttribute('data-cy') === editorCyName); + + // If Monaco instance is found + if (editor) { + editor.setValue(text); + } else { + throw new Error('Monaco editor not found on the window or frames[0]'); + } + }); +} + +function setEditorScript(script: string) { + setMonacoEditorContents('preflight-script-editor', script); +} + +describe('Preflight Script', () => { + it('mini script editor is read only', () => { + cy.dataCy('toggle-preflight-script').click(); + // Wait loading disappears + cy.dataCy('preflight-script-editor-mini').should('not.contain', 'Loading'); + // Click + cy.dataCy('preflight-script-editor-mini').click(); + // And type + cy.dataCy('preflight-script-editor-mini').within(() => { + cy.get('textarea').type('🐝', { force: true }); + }); + cy.dataCy('preflight-script-editor-mini').should( + 'have.text', + 'Cannot edit in read-only editor', + ); + }); +}); + +describe('Preflight Script Modal', () => { + const script = 'console.log("Hello_world")'; + const env = '{"foo":123}'; + + beforeEach(() => { + cy.dataCy('preflight-script-modal-button').click(); + setMonacoEditorContents('env-editor', env); + }); + + it('save script and environment variables when submitting', () => { + setEditorScript(script); + cy.dataCy('preflight-script-modal-submit').click(); + cy.dataCy('env-editor-mini').should('have.text', env); + cy.dataCy('toggle-preflight-script').click(); + cy.dataCy('preflight-script-editor-mini').should('have.text', script); + cy.reload(); + cy.get('[aria-label*="Preflight Script"]').click(); + cy.dataCy('env-editor-mini').should('have.text', env); + cy.dataCy('preflight-script-editor-mini').should('have.text', script); + }); + + it('logs show console/error information', () => { + setEditorScript(script); + cy.dataCy('run-preflight-script').click(); + cy.dataCy('console-output').should('contain', 'Log: Hello_world (Line: 1, Column: 1)'); + + setEditorScript( + `console.info(1) +console.warn(true) +console.error('Fatal') +throw new TypeError('Test')`, + ); + + cy.dataCy('run-preflight-script').click(); + // First log previous log message + cy.dataCy('console-output').should('contain', 'Log: Hello_world (Line: 1, Column: 1)'); + // After the new logs + cy.dataCy('console-output').should( + 'contain', + [ + 'Info: 1 (Line: 1, Column: 1)', + 'Warn: true (Line: 2, Column: 1)', + 'Error: Fatal (Line: 3, Column: 1)', + 'TypeError: Test (Line: 4, Column: 7)', + ].join(''), + ); + }); + + it('script execution updates environment variables', () => { + setEditorScript(`lab.environment.set('my-test', "TROLOLOL")`); + + cy.dataCy('run-preflight-script').click(); + cy.dataCy('env-editor').should( + 'include.text', + // replace space with   + '{ "foo": 123, "my-test": "TROLOLOL"}'.replaceAll(' ', '\xa0'), + ); + }); + + it('`crypto-js` can be used for generating hashes', () => { + setEditorScript('console.log(lab.CryptoJS.SHA256("🐝"))'); + cy.dataCy('run-preflight-script').click(); + cy.dataCy('console-output').should('contain', 'Info: Using crypto-js version:'); + cy.dataCy('console-output').should( + 'contain', + 'Log: d5b51e79e4be0c4f4d6b9a14e16ca864de96afe68459e60a794e80393a4809e8', + ); + }); + + it('scripts can not use `eval`', () => { + setEditorScript('eval()'); + cy.dataCy('preflight-script-modal-submit').click(); + cy.get('body').contains('Usage of dangerous statement like eval() or Function("").'); + }); + + it('invalid code is rejected and can not be saved', () => { + setEditorScript('🐝'); + cy.dataCy('preflight-script-modal-submit').click(); + cy.get('body').contains("[1:1]: Illegal character '}"); + }); +}); + +describe('Execution', () => { + it('header placeholders are substituted with environment variables', () => { + cy.dataCy('toggle-preflight-script').click(); + cy.get('[data-name="headers"]').click(); + cy.get('.graphiql-editor-tool .graphiql-editor:last-child textarea').type( + '{ "__test": "{{foo}} bar {{nonExist}}" }', + { + force: true, + parseSpecialCharSequences: false, + }, + ); + cy.dataCy('env-editor-mini').within(() => { + cy.get('textarea').type('{"foo":"injected"}', { + force: true, + parseSpecialCharSequences: false, + }); + }); + cy.intercept('/api/lab/foo/my-new-project/development', req => { + expect(req.headers.__test).to.equal('injected bar {{nonExist}}'); + }); + cy.get('body').type('{ctrl}{enter}'); + }); + + it('executed script updates update env editor and substitute headers', () => { + cy.dataCy('toggle-preflight-script').click(); + cy.get('[data-name="headers"]').click(); + cy.get('.graphiql-editor-tool .graphiql-editor:last-child textarea').type( + '{ "__test": "{{foo}}" }', + { + force: true, + parseSpecialCharSequences: false, + }, + ); + cy.dataCy('preflight-script-modal-button').click(); + setMonacoEditorContents('preflight-script-editor', `lab.environment.set('foo', 92)`); + cy.dataCy('preflight-script-modal-submit').click(); + cy.intercept('/api/lab/foo/my-new-project/development', req => { + expect(req.headers.__test).to.equal('92'); + }); + cy.get('.graphiql-execute-button').click(); + }); + + it('disabled script is not executed', () => { + cy.get('[data-name="headers"]').click(); + cy.get('.graphiql-editor-tool .graphiql-editor:last-child textarea').type( + '{ "__test": "{{foo}}" }', + { + force: true, + parseSpecialCharSequences: false, + }, + ); + cy.dataCy('preflight-script-modal-button').click(); + setMonacoEditorContents('preflight-script-editor', `lab.environment.set('foo', 92)`); + setMonacoEditorContents('env-editor', `{"foo":10}`); + + cy.dataCy('preflight-script-modal-submit').click(); + cy.intercept('/api/lab/foo/my-new-project/development', req => { + expect(req.headers.__test).to.equal('10'); + }); + cy.get('.graphiql-execute-button').click(); + }); +}); diff --git a/cypress/local.sh b/cypress/local.sh index b7ecdf5039..621802ee76 100755 --- a/cypress/local.sh +++ b/cypress/local.sh @@ -25,7 +25,7 @@ cd .. docker buildx bake -f docker/docker.hcl build --load echo "⬆️ Running all local containers..." -docker compose -f ./docker/docker-compose.community.yml -f ./docker/docker-compose.end2end.yml --env-file ./integration-tests/.env --env-file ./docker/.end2end.env up -d --wait +docker compose -f ./docker/docker-compose.community.yml -f ./docker/docker-compose.end2end.yml --env-file ./integration-tests/.env up -d --wait echo "✅ E2E tests environment is ready. To run tests now, use:" echo "" diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index 548af3377a..f545e65eae 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "es5", - "lib": ["es5", "dom"], + "target": "es2021", + "lib": ["es2021", "dom"], "types": ["node", "cypress"] }, - "include": ["**/*.ts"] + "include": ["**/*.ts", "../integration-tests/testkit/**/*.ts"] } diff --git a/deployment/index.ts b/deployment/index.ts index ac0c224192..6828b95307 100644 --- a/deployment/index.ts +++ b/deployment/index.ts @@ -335,12 +335,10 @@ deployCloudFlareSecurityTransform({ // Staging 'staging.graphql-hive.com', 'app.staging.graphql-hive.com', - 'lab-worker.staging.graphql-hive.com', 'cdn.staging.graphql-hive.com', // Dev 'dev.graphql-hive.com', 'app.dev.graphql-hive.com', - 'lab-worker.dev.graphql-hive.com', 'cdn.dev.graphql-hive.com', ], }); @@ -353,4 +351,4 @@ export const schemaApiServiceId = schema.service.id; export const webhooksApiServiceId = webhooks.service.id; export const appId = app.deployment.id; -export const publicIp = proxy!.status.loadBalancer.ingress[0].ip; +export const publicIp = proxy.get()!.status.loadBalancer.ingress[0].ip; diff --git a/deployment/services/app.ts b/deployment/services/app.ts index 81a37d9ab9..aa176ecb14 100644 --- a/deployment/services/app.ts +++ b/deployment/services/app.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'crypto'; import * as pulumi from '@pulumi/pulumi'; import { serviceLocalEndpoint } from '../utils/local-endpoint'; import { ServiceDeployment } from '../utils/service-deployment'; diff --git a/deployment/services/cloudflare-security.ts b/deployment/services/cloudflare-security.ts index f4aab838dc..1cd5d8eaae 100644 --- a/deployment/services/cloudflare-security.ts +++ b/deployment/services/cloudflare-security.ts @@ -34,7 +34,6 @@ export function deployCloudFlareSecurityTransform(options: { )} } and not http.host in { ${toExpressionList(options.ignoredHosts)} }`; // TODO: When Preflight PR is merged, we'll need to change this to build this host in a better way. - const labHost = `lab-worker.${options.environment.rootDns}`; const monacoCdnDynamicBasePath: `https://${string}/` = `https://cdn.jsdelivr.net/npm/monaco-editor@${monacoEditorVersion}/`; const monacoCdnStaticBasePath: `https://${string}/` = `https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/`; const crispHost = 'client.crisp.chat'; @@ -44,7 +43,6 @@ export function deployCloudFlareSecurityTransform(options: { crispHost, stripeHost, gtmHost, - labHost, 'settings.crisp.chat', '*.ingest.sentry.io', 'wss://client.relay.crisp.chat', @@ -57,7 +55,6 @@ export function deployCloudFlareSecurityTransform(options: { const contentSecurityPolicy = ` default-src 'self'; frame-src ${stripeHost} https://game.crisp.chat; - worker-src 'self' blob: ${labHost}; style-src 'self' 'unsafe-inline' ${crispHost} fonts.googleapis.com rsms.me ${monacoCdnDynamicBasePath} ${monacoCdnStaticBasePath}; script-src 'self' 'unsafe-eval' 'unsafe-inline' {DYNAMIC_HOST_PLACEHOLDER} ${monacoCdnDynamicBasePath} ${monacoCdnStaticBasePath} ${cspHosts}; connect-src 'self' * {DYNAMIC_HOST_PLACEHOLDER} ${cspHosts}; diff --git a/deployment/services/proxy.ts b/deployment/services/proxy.ts index 9249c679f3..88b6753aca 100644 --- a/deployment/services/proxy.ts +++ b/deployment/services/proxy.ts @@ -100,6 +100,5 @@ export function deployProxy({ service: usage.service, retriable: true, }, - ]) - .get(); + ]); } diff --git a/deployment/utils/reverse-proxy.ts b/deployment/utils/reverse-proxy.ts index c5d2290835..23a005ecc2 100644 --- a/deployment/utils/reverse-proxy.ts +++ b/deployment/utils/reverse-proxy.ts @@ -14,6 +14,67 @@ export class Proxy { private staticIp?: { address?: string; aksReservedIpResourceGroup?: string }, ) {} + registerInternalProxy( + dnsRecord: string, + route: { + path: string; + service: k8s.core.v1.Service; + host: string; + customRewrite: string; + }, + ) { + const cert = new k8s.apiextensions.CustomResource(`cert-${dnsRecord}`, { + apiVersion: 'cert-manager.io/v1', + kind: 'Certificate', + metadata: { + name: dnsRecord, + }, + spec: { + commonName: dnsRecord, + dnsNames: [dnsRecord], + issuerRef: { + name: this.tlsSecretName, + kind: 'ClusterIssuer', + }, + secretName: dnsRecord, + }, + }); + + new k8s.apiextensions.CustomResource( + `internal-proxy-${dnsRecord}`, + { + apiVersion: 'projectcontour.io/v1', + kind: 'HTTPProxy', + metadata: { + name: `internal-proxy-metadata-${dnsRecord}`, + }, + spec: { + virtualhost: { + fqdn: route.host, + tls: { + secretName: dnsRecord, + }, + }, + routes: [ + { + conditions: [{ prefix: route.path }], + services: [ + { + name: route.service.metadata.name, + port: route.service.spec.ports[0].port, + }, + ], + pathRewritePolicy: { + replacePrefix: [{ prefix: route.path, replacement: route.customRewrite }], + }, + }, + ], + }, + }, + { dependsOn: [cert, this.lbService!] }, + ); + } + registerService( dns: { record: string; apex?: boolean }, routes: { @@ -29,7 +90,7 @@ export class Proxy { withWwwDomain?: boolean; // https://projectcontour.io/docs/1.29/config/rate-limiting/#local-rate-limiting rateLimit?: { - // Max amount of request allowed with the "unit" paramter. + // Max amount of request allowed with the "unit" parameter. maxRequests: number; unit: 'second' | 'minute' | 'hour'; // defining the number of requests above the baseline rate that are allowed in a short period of time. diff --git a/docker/.end2end.env b/docker/.end2end.env deleted file mode 100644 index 7a5563f670..0000000000 --- a/docker/.end2end.env +++ /dev/null @@ -1,13 +0,0 @@ -export HIVE_ENCRYPTION_SECRET=wowverysecuremuchsecret -export HIVE_EMAIL_FROM=no-reply@graphql-hive.com -export HIVE_APP_BASE_URL=http://localhost:8080 -export SUPERTOKENS_API_KEY=wowverysecuremuchsecret -export CLICKHOUSE_USER=clickhouse -export CLICKHOUSE_PASSWORD=wowverysecuremuchsecret -export REDIS_PASSWORD=wowverysecuremuchsecret -export POSTGRES_PASSWORD=postgres -export POSTGRES_USER=postgres -export POSTGRES_DB=registry -export MINIO_ROOT_USER=minioadmin -export MINIO_ROOT_PASSWORD=minioadmin -export CDN_AUTH_PRIVATE_KEY=6b4721a99bd2ef6c00ce4328f34d95d7 diff --git a/docker/docker-compose.end2end.yml b/docker/docker-compose.end2end.yml index 4b7f2052c8..2a8b710bad 100644 --- a/docker/docker-compose.end2end.yml +++ b/docker/docker-compose.end2end.yml @@ -34,5 +34,9 @@ services: networks: - 'stack' + supertokens: + ports: + - '3567:3567' + networks: stack: {} diff --git a/docs/TESTING.md b/docs/TESTING.md index 0a42828678..fbcec6f70e 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -68,29 +68,18 @@ To run integration tests locally, from the pre-build Docker image, follow: e2e Tests are based on Cypress, and matches files that ends with `.cy.ts`. The tests flow runs from a pre-build Docker image. -#### Running from Source Code +#### Running on built Docker images from source code To run e2e tests locally, from the local source code, follow: 1. Make sure you have Docker installed. If you are having issues, try to run `docker system prune` to clean the Docker caches. 2. Install all deps: `pnpm i` -3. Generate types: `pnpm graphql:generate` -4. Build source code: `pnpm build` -5. Set env vars: - ```bash - export COMMIT_SHA="local" - export RELEASE="local" - export BRANCH_NAME="local" - export BUILD_TYPE="" - export DOCKER_TAG=":local" - ``` -6. Compile a local Docker image by running: `docker buildx bake -f docker/docker.hcl build --load` -7. Run the e2e environment, by running: - `docker compose -f ./docker/docker-compose.community.yml -f ./docker/docker-compose.end2end.yml --env-file ./integration-tests/.env up -d --wait` -8. Run Cypress: `pnpm test:e2e` +3. Move into the `cypress` folder (`cd cypress`) +4. Run `./local.sh` for building the project and starting the Docker containers +5. Follow the output instruction from the script for starting the tests -#### Running from Pre-Built Docker Image +#### Running from pre-built Docker image To run integration tests locally, from the pre-build Docker image, follow: @@ -105,7 +94,13 @@ To run integration tests locally, from the pre-build Docker image, follow: export DOCKER_TAG=":IMAGE_TAG_HERE" ``` 6. Run the e2e environment, by running: - `docker compose -f ./docker/docker-compose.community.yml --env-file ./integration-tests/.env up -d --wait` + ``` + docker compose \ + -f ./docker/docker-compose.community.yml \ + -f ./docker/docker-compose.end2end.yml \ + --env-file ./integration-tests/.env \ + up -d --wait + ``` 7. Run Cypress: `pnpm test:e2e` #### Docker Compose Configuration diff --git a/integration-tests/testkit/auth.ts b/integration-tests/testkit/auth.ts index 51b11b2414..107aedc849 100644 --- a/integration-tests/testkit/auth.ts +++ b/integration-tests/testkit/auth.ts @@ -131,6 +131,7 @@ const createSession = async ( */ return { access_token: data.accessToken.token, + refresh_token: data.refreshToken.token, }; } catch (e) { console.warn(`Failed to create session:`, e); @@ -148,15 +149,17 @@ const tokenResponsePromise: { [key: string]: Promise> | null; } = {}; -export function authenticate(email: string): Promise<{ access_token: string }>; +export function authenticate( + email: string, +): Promise<{ access_token: string; refresh_token: string }>; export function authenticate( email: string, oidcIntegrationId?: string, -): Promise<{ access_token: string }>; +): Promise<{ access_token: string; refresh_token: string }>; export function authenticate( email: string | string, oidcIntegrationId?: string, -): Promise<{ access_token: string }> { +): Promise<{ access_token: string; refresh_token: string }> { if (!tokenResponsePromise[email]) { tokenResponsePromise[email] = signUpUserViaEmail(email, password); } diff --git a/integration-tests/testkit/collections.ts b/integration-tests/testkit/collections.ts index 9cff9d4a04..e501162ac5 100644 --- a/integration-tests/testkit/collections.ts +++ b/integration-tests/testkit/collections.ts @@ -202,3 +202,22 @@ export const DeleteOperationMutation = graphql(` } } `); + +export const UpdatePreflightScriptMutation = graphql(` + mutation UpdatePreflightScript($input: UpdatePreflightScriptInput!) { + updatePreflightScript(input: $input) { + ok { + updatedTarget { + id + preflightScript { + id + sourceCode + } + } + } + error { + message + } + } + } +`); diff --git a/integration-tests/testkit/schema-policy.ts b/integration-tests/testkit/schema-policy.ts index 9602e7e986..663a62fd3f 100644 --- a/integration-tests/testkit/schema-policy.ts +++ b/integration-tests/testkit/schema-policy.ts @@ -1,5 +1,5 @@ -import { RuleInstanceSeverityLevel, SchemaPolicyInput } from 'testkit/gql/graphql'; import { graphql } from './gql'; +import { RuleInstanceSeverityLevel, SchemaPolicyInput } from './gql/graphql'; export const OrganizationAndProjectsWithSchemaPolicy = graphql(` query OrganizationAndProjectsWithSchemaPolicy($organization: String!) { diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index 5ee9b61503..83ebb22fca 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -1,13 +1,5 @@ import { humanId } from 'human-id'; import { createPool, sql } from 'slonik'; -import { - OrganizationAccessScope, - ProjectAccessScope, - ProjectType, - RegistryModel, - SchemaPolicyInput, - TargetAccessScope, -} from 'testkit/gql/graphql'; import type { Report } from '../../packages/libraries/core/src/client/usage.js'; import { authenticate, userEmail } from './auth'; import { @@ -17,6 +9,7 @@ import { DeleteOperationMutation, UpdateCollectionMutation, UpdateOperationMutation, + UpdatePreflightScriptMutation, } from './collections'; import { ensureEnv } from './env'; import { @@ -57,21 +50,29 @@ import { updateSchemaVersionStatus, updateTargetValidationSettings, } from './flow'; +import { + OrganizationAccessScope, + ProjectAccessScope, + ProjectType, + RegistryModel, + SchemaPolicyInput, + TargetAccessScope, +} from './gql/graphql'; import { execute } from './graphql'; import { UpdateSchemaPolicyForOrganization, UpdateSchemaPolicyForProject } from './schema-policy'; import { collect, CollectedOperation, legacyCollect } from './usage'; import { generateUnique } from './utils'; export function initSeed() { - const pg = { - user: ensureEnv('POSTGRES_USER'), - password: ensureEnv('POSTGRES_PASSWORD'), - host: ensureEnv('POSTGRES_HOST'), - port: ensureEnv('POSTGRES_PORT'), - db: ensureEnv('POSTGRES_DB'), - }; - function createConnectionPool() { + const pg = { + user: ensureEnv('POSTGRES_USER'), + password: ensureEnv('POSTGRES_PASSWORD'), + host: ensureEnv('POSTGRES_HOST'), + port: ensureEnv('POSTGRES_PORT'), + db: ensureEnv('POSTGRES_DB'), + }; + return createPool( `postgres://${pg.user}:${pg.password}@${pg.host}:${pg.port}/${pg.db}?sslmode=disable`, ); @@ -87,15 +88,18 @@ export function initSeed() { }, }; }, - authenticate: authenticate, + authenticate, generateEmail: () => userEmail(generateUnique()), async createOwner() { const ownerEmail = userEmail(generateUnique()); - const ownerToken = await authenticate(ownerEmail).then(r => r.access_token); + const auth = await authenticate(ownerEmail); + const ownerRefreshToken = auth.refresh_token; + const ownerToken = auth.access_token; return { ownerEmail, ownerToken, + ownerRefreshToken, async createOrg() { const orgSlug = generateUnique(); const orgResult = await createOrganization({ slug: orgSlug }, ownerToken).then(r => @@ -296,6 +300,30 @@ export function initSeed() { return result.createDocumentCollection; }, + async updatePreflightScript({ + sourceCode, + token = ownerToken, + }: { + sourceCode: string; + token?: string; + }) { + const result = await execute({ + document: UpdatePreflightScriptMutation, + variables: { + input: { + selector: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + }, + sourceCode, + }, + }, + authToken: token, + }).then(r => r.expectNoGraphQLErrors()); + + return result.updatePreflightScript; + }, async updateDocumentCollection({ collectionId, name, diff --git a/integration-tests/tests/api/collections/document-preflight-scripts.spec.ts b/integration-tests/tests/api/collections/document-preflight-scripts.spec.ts new file mode 100644 index 0000000000..9d9e657128 --- /dev/null +++ b/integration-tests/tests/api/collections/document-preflight-scripts.spec.ts @@ -0,0 +1,41 @@ +import { ProjectType } from 'testkit/gql/graphql'; +import { initSeed } from '../../../testkit/seed'; + +describe('Preflight Script', () => { + describe('CRUD', () => { + const rawJs = 'console.log("Hello World")'; + + it.concurrent('Update a Preflight Script', async () => { + const { updatePreflightScript } = await initSeed() + .createOwner() + .then(r => r.createOrg()) + .then(r => r.createProject(ProjectType.Single)); + + const { error, ok } = await updatePreflightScript({ sourceCode: rawJs }); + expect(error).toEqual(null); + expect(ok?.updatedTarget.preflightScript?.id).toBeDefined(); + expect(ok?.updatedTarget.preflightScript?.sourceCode).toBe(rawJs); + }); + + describe('Permissions Check', () => { + it('Prevent updating a Preflight Script without the write permission to the target', async () => { + const { updatePreflightScript, createTargetAccessToken } = await initSeed() + .createOwner() + .then(r => r.createOrg()) + .then(r => r.createProject(ProjectType.Single)); + + const { secret: readOnlyToken } = await createTargetAccessToken({ mode: 'readOnly' }); + + await expect( + updatePreflightScript({ sourceCode: rawJs, token: readOnlyToken }), + ).rejects.toEqual( + expect.objectContaining({ + message: expect.stringContaining( + `No access (reason: "Missing permission for performing 'laboratory:modifyPreflightScript' on resource")`, + ), + }), + ); + }); + }); + }); +}); diff --git a/package.json b/package.json index ee20c881ea..3f67880031 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "seed": "tsx scripts/seed-local-env.ts", "start": "pnpm run local:setup", "test": "vitest", - "test:e2e": "CYPRESS_BASE_URL=$HIVE_APP_BASE_URL cypress run", + "test:e2e": "CYPRESS_BASE_URL=$HIVE_APP_BASE_URL cypress run --browser chrome", "test:e2e:open": "CYPRESS_BASE_URL=$HIVE_APP_BASE_URL cypress open", "test:integration": "cd integration-tests && pnpm test:integration", "typecheck": "pnpm run -r --filter '!hive' typecheck", @@ -119,9 +119,11 @@ "slonik@30.4.4": "patches/slonik@30.4.4.patch", "@oclif/core@3.26.6": "patches/@oclif__core@3.26.6.patch", "oclif@4.13.6": "patches/oclif@4.13.6.patch", - "@graphiql/react@1.0.0-alpha.3": "patches/@graphiql__react@1.0.0-alpha.3.patch", + "graphiql": "patches/graphiql.patch", + "@graphiql/react": "patches/@graphiql__react.patch", "countup.js": "patches/countup.js.patch", - "@oclif/core@4.0.6": "patches/@oclif__core@4.0.6.patch" + "@oclif/core@4.0.6": "patches/@oclif__core@4.0.6.patch", + "@fastify/vite": "patches/@fastify__vite.patch" } } } diff --git a/packages/migrations/src/actions/2024.12.27T00.00.00.create-preflight-scripts.ts b/packages/migrations/src/actions/2024.12.27T00.00.00.create-preflight-scripts.ts new file mode 100644 index 0000000000..56868b901a --- /dev/null +++ b/packages/migrations/src/actions/2024.12.27T00.00.00.create-preflight-scripts.ts @@ -0,0 +1,23 @@ +import { type MigrationExecutor } from '../pg-migrator'; + +export default { + name: '2024.12.27T00.00.00.create-preflight-scripts.ts', + run: ({ sql }) => sql` +CREATE TABLE IF NOT EXISTS "document_preflight_scripts" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "source_code" text NOT NULL, + "target_id" uuid NOT NULL UNIQUE REFERENCES "targets"("id") ON DELETE CASCADE, + "created_by_user_id" uuid REFERENCES "users"("id") ON DELETE SET NULL, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("id") +); + +ALTER TABLE "document_preflight_scripts" + ADD CONSTRAINT "unique_target_id" UNIQUE ("target_id"); + +CREATE INDEX IF NOT EXISTS "document_preflight_scripts_target" ON "document_preflight_scripts" ( + "target_id" ASC +); +`, +} satisfies MigrationExecutor; diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index d8ca2097b2..b8a42f593b 100644 --- a/packages/migrations/src/run-pg-migrations.ts +++ b/packages/migrations/src/run-pg-migrations.ts @@ -149,5 +149,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri await import('./actions/2024.11.12T00-00-00.supertokens-9.3'), await import('./actions/2024.12.23T00-00-00.improve-version-index'), await import('./actions/2024.12.24T00-00-00.improve-version-index-2'), + await import('./actions/2024.12.27T00.00.00.create-preflight-scripts'), ], }); diff --git a/packages/services/api/package.json b/packages/services/api/package.json index 3bd6aa39cf..0c20ad392d 100644 --- a/packages/services/api/package.json +++ b/packages/services/api/package.json @@ -25,6 +25,8 @@ "@hive/usage-common": "workspace:*", "@hive/usage-ingestor": "workspace:*", "@hive/webhooks": "workspace:*", + "@nodesecure/i18n": "^4.0.1", + "@nodesecure/js-x-ray": "8.0.0", "@octokit/app": "14.1.0", "@octokit/core": "5.2.0", "@octokit/plugin-retry": "6.1.0", diff --git a/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts b/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts index 1e0246aec7..3cfbe9de3a 100644 --- a/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts +++ b/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts @@ -321,6 +321,12 @@ export const AuditLogModel = z.union([ updatedFields: z.string(), }), }), + z.object({ + eventType: z.literal('PREFLIGHT_SCRIPT_CHANGED'), + metadata: z.object({ + scriptContents: z.string(), + }), + }), ]); export type AuditLogSchemaEvent = z.infer; diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index 3bcda2600c..47e0b39f90 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -350,6 +350,7 @@ const actionDefinitions = { 'target:modifySettings': defaultTargetIdentity, 'laboratory:describe': defaultTargetIdentity, 'laboratory:modify': defaultTargetIdentity, + 'laboratory:modifyPreflightScript': defaultTargetIdentity, 'appDeployment:describe': defaultTargetIdentity, 'appDeployment:create': defaultAppDeploymentIdentity, 'appDeployment:publish': defaultAppDeploymentIdentity, diff --git a/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts b/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts index eadbcaaf30..c21ade4248 100644 --- a/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts +++ b/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts @@ -307,7 +307,7 @@ function transformOrganizationMemberLegacyScopes(args: { case TargetAccessScope.SETTINGS: { policies.push({ effect: 'allow', - action: ['target:modifySettings'], + action: ['target:modifySettings', 'laboratory:modifyPreflightScript'], resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], }); break; diff --git a/packages/services/api/src/modules/lab/index.ts b/packages/services/api/src/modules/lab/index.ts index dfefadd456..22b90f387b 100644 --- a/packages/services/api/src/modules/lab/index.ts +++ b/packages/services/api/src/modules/lab/index.ts @@ -1,4 +1,5 @@ import { createModule } from 'graphql-modules'; +import { PreflightScriptProvider } from './providers/preflight-script.provider'; import { resolvers } from './resolvers.generated'; import typeDefs from './module.graphql'; @@ -7,5 +8,5 @@ export const labModule = createModule({ dirname: __dirname, typeDefs, resolvers, - providers: [], + providers: [PreflightScriptProvider], }); diff --git a/packages/services/api/src/modules/lab/module.graphql.ts b/packages/services/api/src/modules/lab/module.graphql.ts index caed0e5bd3..4463509418 100644 --- a/packages/services/api/src/modules/lab/module.graphql.ts +++ b/packages/services/api/src/modules/lab/module.graphql.ts @@ -8,4 +8,41 @@ export default gql` schema: String! mocks: JSON } + + type PreflightScript { + id: ID! + sourceCode: String! + createdAt: DateTime! + updatedAt: DateTime! + } + + input UpdatePreflightScriptInput { + selector: TargetSelectorInput! + sourceCode: String! + } + + extend type Mutation { + updatePreflightScript(input: UpdatePreflightScriptInput!): PreflightScriptResult! + } + + """ + @oneOf + """ + type PreflightScriptResult { + ok: PreflightScriptOk + error: PreflightScriptError + } + + type PreflightScriptOk { + preflightScript: PreflightScript! + updatedTarget: Target! + } + + type PreflightScriptError implements Error { + message: String! + } + + extend type Target { + preflightScript: PreflightScript + } `; diff --git a/packages/services/api/src/modules/lab/providers/preflight-script.provider.ts b/packages/services/api/src/modules/lab/providers/preflight-script.provider.ts new file mode 100644 index 0000000000..bd85ea02ba --- /dev/null +++ b/packages/services/api/src/modules/lab/providers/preflight-script.provider.ts @@ -0,0 +1,197 @@ +import { Inject, Injectable, Scope } from 'graphql-modules'; +import { sql, type DatabasePool } from 'slonik'; +import { z } from 'zod'; +import { getLocalLang, getTokenSync } from '@nodesecure/i18n'; +import * as jsxray from '@nodesecure/js-x-ray'; +import type { Target } from '../../../shared/entities'; +import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; +import { Session } from '../../auth/lib/authz'; +import { IdTranslator } from '../../shared/providers/id-translator'; +import { Logger } from '../../shared/providers/logger'; +import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; +import { Storage } from '../../shared/providers/storage'; + +const SourceCodeModel = z.string().max(5_000); + +const UpdatePreflightScriptModel = z.strictObject({ + // Use validation only on insertion + sourceCode: SourceCodeModel.superRefine((val, ctx) => { + try { + const { warnings } = scanner.analyse(val); + for (const warning of warnings) { + const message = getTokenSync(jsxray.warnings[warning.kind].i18n); + throw new Error(message); + } + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: error instanceof Error ? error.message : String(error), + }); + } + }), +}); + +const PreflightScriptModel = z.strictObject({ + id: z.string(), + sourceCode: SourceCodeModel, + targetId: z.string(), + createdByUserId: z.union([z.string(), z.null()]), + createdAt: z.string(), + updatedAt: z.string(), +}); + +type PreflightScript = z.TypeOf; + +const scanner = new jsxray.AstAnalyser(); +await getLocalLang(); + +@Injectable({ + global: true, + scope: Scope.Operation, +}) +export class PreflightScriptProvider { + private logger: Logger; + + constructor( + logger: Logger, + private storage: Storage, + private session: Session, + private idTranslator: IdTranslator, + private auditLogs: AuditLogRecorder, + @Inject(PG_POOL_CONFIG) private pool: DatabasePool, + ) { + this.logger = logger.child({ source: 'PreflightScriptProvider' }); + } + + async getPreflightScript(targetId: string) { + const result = await this.pool.maybeOne(sql`/* getPreflightScript */ + SELECT + "id" + , "source_code" AS "sourceCode" + , "target_id" AS "targetId" + , "created_by_user_id" AS "createdByUserId" + , to_json("created_at") AS "createdAt" + , to_json("updated_at") AS "updatedAt" + FROM + "document_preflight_scripts" + WHERE + "target_id" = ${targetId} + `); + + if (!result) { + return null; + } + + return PreflightScriptModel.parse(result); + } + + async updatePreflightScript(args: { + selector: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + }; + sourceCode: string; + }): Promise< + | { + error: { message: string }; + ok?: never; + } + | { + error?: never; + ok: { + preflightScript: PreflightScript; + updatedTarget: Target; + }; + } + > { + const [organizationId, projectId, targetId] = await Promise.all([ + this.idTranslator.translateOrganizationId(args.selector), + this.idTranslator.translateProjectId(args.selector), + this.idTranslator.translateTargetId(args.selector), + ]); + + await this.session.assertPerformAction({ + action: 'laboratory:modifyPreflightScript', + organizationId, + params: { + organizationId, + projectId, + targetId, + }, + }); + + const validationResult = UpdatePreflightScriptModel.safeParse({ sourceCode: args.sourceCode }); + + if (validationResult.error) { + return { + error: { + message: validationResult.error.errors[0].message, + }, + }; + } + + const currentUser = await this.session.getViewer(); + const result = await this.pool.maybeOne(sql`/* createPreflightScript */ + INSERT INTO "document_preflight_scripts" ( + "source_code" + , "target_id" + , "created_by_user_id") + VALUES ( + ${validationResult.data.sourceCode} + , ${targetId} + , ${currentUser.id} + ) + ON CONFLICT ("target_id") + DO UPDATE + SET + "source_code" = EXCLUDED."source_code" + , "updated_at" = NOW() + RETURNING + "id" + , "source_code" AS "sourceCode" + , "target_id" AS "targetId" + , "created_by_user_id" AS "createdByUserId" + , to_json("created_at") AS "createdAt" + , to_json("updated_at") AS "updatedAt" + `); + + if (!result) { + return { + error: { + message: 'No preflight script found', + }, + }; + } + const { data: preflightScript, error } = PreflightScriptModel.safeParse(result); + + if (error) { + return { + error: { + message: error.errors[0].message, + }, + }; + } + + await this.auditLogs.record({ + eventType: 'PREFLIGHT_SCRIPT_CHANGED', + organizationId, + metadata: { + scriptContents: preflightScript.sourceCode, + }, + }); + + const updatedTarget = await this.storage.getTarget({ + organizationId, + projectId, + targetId, + }); + + return { + ok: { + preflightScript, + updatedTarget, + }, + }; + } +} diff --git a/packages/services/api/src/modules/lab/resolvers/Mutation/updatePreflightScript.ts b/packages/services/api/src/modules/lab/resolvers/Mutation/updatePreflightScript.ts new file mode 100644 index 0000000000..071c45af38 --- /dev/null +++ b/packages/services/api/src/modules/lab/resolvers/Mutation/updatePreflightScript.ts @@ -0,0 +1,23 @@ +import { MutationResolvers } from '../../../../__generated__/types'; +import { PreflightScriptProvider } from '../../providers/preflight-script.provider'; + +export const updatePreflightScript: NonNullable< + MutationResolvers['updatePreflightScript'] +> = async (_parent, args, { injector }) => { + const result = await injector.get(PreflightScriptProvider).updatePreflightScript({ + selector: args.input.selector, + sourceCode: args.input.sourceCode, + }); + + if (result.error) { + return { + error: result.error, + ok: null, + }; + } + + return { + ok: result.ok, + error: null, + }; +}; diff --git a/packages/services/api/src/modules/lab/resolvers/Target.ts b/packages/services/api/src/modules/lab/resolvers/Target.ts new file mode 100644 index 0000000000..640f6e024e --- /dev/null +++ b/packages/services/api/src/modules/lab/resolvers/Target.ts @@ -0,0 +1,16 @@ +import type { TargetResolvers } from '../../../__generated__/types'; +import { PreflightScriptProvider } from '../providers/preflight-script.provider'; + +/* + * Note: This object type is generated because "TargetMapper" is declared. This is to ensure runtime safety. + * + * When a mapper is used, it is possible to hit runtime errors in some scenarios: + * - given a field name, the schema type's field type does not match mapper's field type + * - or a schema type's field does not exist in the mapper's fields + * + * If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config. + */ +export const Target: Pick = { + preflightScript: (parent, _args, { injector }) => + injector.get(PreflightScriptProvider).getPreflightScript(parent.id), +}; diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index fc6f61cb7c..c9d2947a4f 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -240,6 +240,15 @@ export interface DocumentCollection { updatedAt: string; } +export interface PreflightScript { + id: string; + sourceCode: string; + targetId: string; + createdByUserId: string | null; + createdAt: string; + updatedAt: string; +} + export type PaginatedDocumentCollections = Readonly<{ edges: ReadonlyArray<{ node: DocumentCollection; diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 8fca2bf273..552393d2c9 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -136,6 +136,15 @@ export interface document_collections { updated_at: Date; } +export interface document_preflight_scripts { + created_at: Date; + created_by_user_id: string | null; + id: string; + source_code: string; + target_id: string; + updated_at: Date; +} + export interface migration { date: Date; hash: string; @@ -417,6 +426,7 @@ export interface DBTables { contracts: contracts; document_collection_documents: document_collection_documents; document_collections: document_collections; + document_preflight_scripts: document_preflight_scripts; migration: migration; oidc_integrations: oidc_integrations; organization_invitations: organization_invitations; diff --git a/packages/web/app/package.json b/packages/web/app/package.json index 5f5160975a..6007a475c6 100644 --- a/packages/web/app/package.json +++ b/packages/web/app/package.json @@ -15,9 +15,9 @@ "@date-fns/utc": "2.1.0", "@fastify/cors": "9.0.1", "@fastify/static": "7.0.4", - "@fastify/vite": "6.0.7", + "@fastify/vite": "6.0.6", "@graphiql/plugin-explorer": "4.0.0-alpha.2", - "@graphiql/react": "1.0.0-alpha.3", + "@graphiql/react": "1.0.0-alpha.4", "@graphiql/toolkit": "0.9.1", "@graphql-codegen/client-preset-swc-plugin": "0.2.0", "@graphql-tools/mock": "9.0.6", @@ -66,6 +66,7 @@ "@theguild/editor": "1.2.5", "@trpc/client": "10.45.2", "@trpc/server": "10.45.2", + "@types/crypto-js": "^4.2.2", "@types/dompurify": "3.2.0", "@types/js-cookie": "3.0.6", "@types/react": "18.3.18", @@ -81,6 +82,7 @@ "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "0.2.1", + "crypto-js": "^4.2.0", "date-fns": "4.1.0", "dompurify": "3.2.3", "dotenv": "16.4.7", @@ -88,8 +90,8 @@ "echarts-for-react": "3.0.2", "fastify": "4.29.0", "formik": "2.4.6", - "framer-motion": "11.15.0", - "graphiql": "4.0.0-alpha.4", + "framer-motion": "11.11.17", + "graphiql": "4.0.0-alpha.5", "graphql": "16.9.0", "graphql-sse": "2.5.3", "immer": "10.1.1", diff --git a/packages/web/app/preflight-worker-embed.html b/packages/web/app/preflight-worker-embed.html new file mode 100644 index 0000000000..b808dba547 --- /dev/null +++ b/packages/web/app/preflight-worker-embed.html @@ -0,0 +1,13 @@ + + + + + + + + I like turtles + Wheatherboi + + + + diff --git a/packages/web/app/src/lib/hooks/laboratory/use-operation-collections-plugin.tsx b/packages/web/app/src/lib/hooks/laboratory/use-operation-collections-plugin.tsx index 6d8d77a609..17ddbc6bb1 100644 --- a/packages/web/app/src/lib/hooks/laboratory/use-operation-collections-plugin.tsx +++ b/packages/web/app/src/lib/hooks/laboratory/use-operation-collections-plugin.tsx @@ -104,6 +104,7 @@ export const TargetLaboratoryPageQuery = graphql(` } viewerCanViewLaboratory viewerCanModifyLaboratory + ...PreflightScript_TargetFragment } ...Laboratory_IsCDNEnabledFragment } @@ -123,11 +124,9 @@ export const operationCollectionsPlugin: GraphiQLPlugin = { }; export function Content() { - const { organizationSlug, projectSlug, targetSlug } = useParams({ strict: false }) as { - organizationSlug: string; - projectSlug: string; - targetSlug: string; - }; + const { organizationSlug, projectSlug, targetSlug } = useParams({ + from: '/authenticated/$organizationSlug/$projectSlug/$targetSlug', + }); const [query] = useQuery({ query: TargetLaboratoryPageQuery, variables: { diff --git a/packages/web/app/src/lib/preflight-sandbox/allowed-globals.ts b/packages/web/app/src/lib/preflight-sandbox/allowed-globals.ts new file mode 100644 index 0000000000..2962653ab4 --- /dev/null +++ b/packages/web/app/src/lib/preflight-sandbox/allowed-globals.ts @@ -0,0 +1,55 @@ +/** + * List all variables that we want to allow users to use inside their scripts + * + * initial list comes from https://github.com/postmanlabs/uniscope/blob/develop/lib/allowed-globals.js + */ +export const ALLOWED_GLOBALS = new Set([ + 'Array', + 'Atomics', + 'BigInt', + 'Boolean', + 'DataView', + 'Date', + 'Error', + 'EvalError', + 'Infinity', + 'JSON', + 'Map', + 'Math', + 'NaN', + 'Number', + 'Object', + 'Promise', + 'Proxy', + 'RangeError', + 'ReferenceError', + 'Reflect', + 'RegExp', + 'Set', + 'String', + 'Symbol', + 'SyntaxError', + 'TypeError', + 'URIError', + 'WeakMap', + 'WeakSet', + 'decodeURI', + 'decodeURIComponent', + 'encodeURI', + 'encodeURIComponent', + 'escape', + 'isFinite', + 'isNaN', + 'parseFloat', + 'parseInt', + 'undefined', + 'unescape', + // More global variables + 'btoa', + 'atob', + 'fetch', + 'setTimeout', + // We aren't allowing access to window.console, but we need to "allow" it + // here so a second argument isn't added for it below. + 'console', +]); diff --git a/packages/web/app/src/lib/preflight-sandbox/graphiql-plugin.tsx b/packages/web/app/src/lib/preflight-sandbox/graphiql-plugin.tsx new file mode 100644 index 0000000000..f369438aa6 --- /dev/null +++ b/packages/web/app/src/lib/preflight-sandbox/graphiql-plugin.tsx @@ -0,0 +1,672 @@ +import { + ComponentPropsWithoutRef, + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { clsx } from 'clsx'; +import type { editor } from 'monaco-editor'; +import { useMutation } from 'urql'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Subtitle, Title } from '@/components/ui/page'; +import { Switch } from '@/components/ui/switch'; +import { useToast } from '@/components/ui/use-toast'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { useLocalStorage, useToggle } from '@/lib/hooks'; +import { GraphiQLPlugin } from '@graphiql/react'; +import { Editor as MonacoEditor, OnMount } from '@monaco-editor/react'; +import { + Cross2Icon, + CrossCircledIcon, + ExclamationTriangleIcon, + InfoCircledIcon, + Pencil1Icon, + TriangleRightIcon, +} from '@radix-ui/react-icons'; +import { useParams } from '@tanstack/react-router'; +import type { LogMessage } from './preflight-script-worker'; + +export const preflightScriptPlugin: GraphiQLPlugin = { + icon: () => ( + + + + + + ), + title: 'Preflight Script', + content: PreflightScriptContent, +}; + +const classes = { + monaco: clsx('*:bg-[#10151f]'), + monacoMini: clsx('h-32 *:rounded-md *:bg-[#10151f]'), + icon: clsx('absolute -left-5 top-px'), +}; + +const sharedMonacoProps = { + theme: 'vs-dark', + className: classes.monaco, + options: { + minimap: { enabled: false }, + padding: { + top: 10, + }, + scrollbar: { + horizontalScrollbarSize: 6, + verticalScrollbarSize: 6, + }, + }, +} satisfies ComponentPropsWithoutRef; + +const monacoProps = { + env: { + ...sharedMonacoProps, + defaultLanguage: 'json', + options: { + ...sharedMonacoProps.options, + lineNumbers: 'off', + tabSize: 2, + }, + }, + script: { + ...sharedMonacoProps, + theme: 'vs-dark', + defaultLanguage: 'javascript', + options: { + ...sharedMonacoProps.options, + }, + }, +} satisfies Record<'script' | 'env', ComponentPropsWithoutRef>; + +type PayloadLog = { type: 'log'; log: string }; +type PayloadError = { type: 'error'; error: Error }; +type PayloadResult = { type: 'result'; environmentVariables: Record }; +type PayloadReady = { type: 'ready' }; + +type WorkerMessagePayload = PayloadResult | PayloadLog | PayloadError | PayloadReady; + +const UpdatePreflightScriptMutation = graphql(` + mutation UpdatePreflightScript($input: UpdatePreflightScriptInput!) { + updatePreflightScript(input: $input) { + ok { + updatedTarget { + id + preflightScript { + id + sourceCode + } + } + } + error { + message + } + } + } +`); + +const PreflightScript_TargetFragment = graphql(` + fragment PreflightScript_TargetFragment on Target { + id + preflightScript { + id + sourceCode + } + } +`); + +type LogRecord = LogMessage | { type: 'separator' }; + +function safeParseJSON(str: string): Record | null { + try { + return JSON.parse(str); + } catch { + return null; + } +} + +const enum PreflightWorkerState { + running, + ready, +} + +export function usePreflightScript(args: { + target: FragmentType | null; +}) { + const iframeRef = useRef(null); + + const target = useFragment(PreflightScript_TargetFragment, args.target); + const [isPreflightScriptEnabled, setIsPreflightScriptEnabled] = useLocalStorage( + 'hive:laboratory:isPreflightScriptEnabled', + false, + ); + const [environmentVariables, setEnvironmentVariables] = useLocalStorage( + 'hive:laboratory:environment', + '', + ); + const latestEnvironmentVariablesRef = useRef(environmentVariables); + useEffect(() => { + latestEnvironmentVariablesRef.current = environmentVariables; + }); + + const [state, setState] = useState(PreflightWorkerState.ready); + const [logs, setLogs] = useState([]); + + const currentRun = useRef(null); + + async function execute(script = target?.preflightScript?.sourceCode ?? '', isPreview = false) { + if (isPreview === false && !isPreflightScriptEnabled) { + return safeParseJSON(latestEnvironmentVariablesRef.current); + } + + const id = crypto.randomUUID(); + setState(PreflightWorkerState.running); + const now = Date.now(); + setLogs(prev => [...prev, '> Start running script']); + + try { + const contentWindow = iframeRef.current?.contentWindow; + + if (!contentWindow) { + throw new Error('Could not load iframe embed.'); + } + + contentWindow.postMessage( + { + type: 'run', + id, + script, + environmentVariables: (environmentVariables && safeParseJSON(environmentVariables)) || {}, + }, + '*', + ); + + const isFinishedD = Promise.withResolvers(); + + // eslint-disable-next-line no-inner-declarations + function eventHandler(ev: MessageEvent) { + if (ev.data.type === 'result') { + const mergedEnvironmentVariables = JSON.stringify( + { + ...safeParseJSON(latestEnvironmentVariablesRef.current), + ...ev.data.environmentVariables, + }, + null, + 2, + ); + setEnvironmentVariables(mergedEnvironmentVariables); + latestEnvironmentVariablesRef.current = mergedEnvironmentVariables; + setLogs(logs => [ + ...logs, + `> End running script. Done in ${(Date.now() - now) / 1000}s`, + { + type: 'separator' as const, + }, + ]); + isFinishedD.resolve(); + return; + } + + if (ev.data.type === 'error') { + const error = ev.data.error; + setLogs(logs => [ + ...logs, + error, + '> Preflight script failed', + { + type: 'separator' as const, + }, + ]); + isFinishedD.resolve(); + return; + } + + if (ev.data.type === 'log') { + const log = ev.data.log; + setLogs(logs => [...logs, log]); + return; + } + } + + window.addEventListener('message', eventHandler); + currentRun.current = () => { + contentWindow.postMessage({ + type: 'abort', + id, + }); + currentRun.current = null; + }; + + await isFinishedD.promise; + window.removeEventListener('message', eventHandler); + + setState(PreflightWorkerState.ready); + return safeParseJSON(latestEnvironmentVariablesRef.current); + } catch (err) { + if (err instanceof Error) { + setLogs(prev => [ + ...prev, + err, + '> Preflight script failed', + { + type: 'separator' as const, + }, + ]); + setState(PreflightWorkerState.ready); + return safeParseJSON(latestEnvironmentVariablesRef.current); + } + throw err; + } + } + + function abort() { + currentRun.current?.(); + } + + // terminate worker when leaving laboratory + useEffect( + () => () => { + currentRun.current?.(); + }, + [], + ); + + return { + execute, + abort, + isPreflightScriptEnabled, + setIsPreflightScriptEnabled, + script: target?.preflightScript?.sourceCode ?? '', + environmentVariables, + setEnvironmentVariables, + state, + logs, + clearLogs: () => setLogs([]), + iframeElement: ( +