Move SuperTokens-node to GraphQL server (#4288)

This commit is contained in:
Kamil Kisiela 2024-03-26 13:42:56 +01:00 committed by GitHub
parent 0db0580949
commit f44fdd474a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
100 changed files with 1244 additions and 1323 deletions

View file

@ -14,7 +14,7 @@ describe('basic user flow', () => {
it('should redirect anon to auth', () => {
cy.visit('/');
cy.url().should('include', '/auth?redirectToPath=%2F');
cy.url().should('include', '/auth?redirectToPath=');
});
it('should sign up', () => {
@ -34,7 +34,7 @@ describe('basic user flow', () => {
// Logout
cy.get('[data-cy="user-menu-trigger"]').click();
cy.get('[data-cy="user-menu-logout"]').click();
cy.url().should('include', '/auth?redirectToPath=%2F');
cy.url().should('include', '/auth?redirectToPath=');
});
});

View file

@ -223,8 +223,6 @@ const app = deployApp({
dbMigrations,
image: docker.factory.getImageId('app', imagesTag),
docker,
supertokens,
emails,
zendesk,
billing,
github: githubApp,

View file

@ -1,33 +1,23 @@
import * as pulumi from '@pulumi/pulumi';
import { serviceLocalEndpoint } from '../utils/local-endpoint';
import { ServiceSecret } from '../utils/secrets';
import { ServiceDeployment } from '../utils/service-deployment';
import { StripeBilling } from './billing';
import { DbMigrations } from './db-migrations';
import { Docker } from './docker';
import { Emails } from './emails';
import { Environment } from './environment';
import { GitHubApp } from './github';
import { GraphQL } from './graphql';
import { Sentry } from './sentry';
import { SlackApp } from './slack-app';
import { Supertokens } from './supertokens';
import { Zendesk } from './zendesk';
export type App = ReturnType<typeof deployApp>;
class AppOAuthSecret extends ServiceSecret<{
clientId: string | pulumi.Output<string>;
clientSecret: string | pulumi.Output<string>;
}> {}
export function deployApp({
graphql,
dbMigrations,
image,
supertokens,
docker,
emails,
zendesk,
github,
slackApp,
@ -40,8 +30,6 @@ export function deployApp({
graphql: GraphQL;
dbMigrations: DbMigrations;
docker: Docker;
supertokens: Supertokens;
emails: Emails;
zendesk: Zendesk;
github: GitHubApp;
slackApp: SlackApp;
@ -51,16 +39,6 @@ export function deployApp({
const appConfig = new pulumi.Config('app');
const appEnv = appConfig.requireObject<Record<string, string>>('env');
const oauthConfig = new pulumi.Config('oauth');
const githubOAuthSecret = new AppOAuthSecret('oauth-github', {
clientId: oauthConfig.requireSecret('githubClient'),
clientSecret: oauthConfig.requireSecret('githubSecret'),
});
const googleOAuthSecret = new AppOAuthSecret('oauth-google', {
clientId: oauthConfig.requireSecret('googleClient'),
clientSecret: oauthConfig.requireSecret('googleSecret'),
});
return new ServiceDeployment(
'app',
{
@ -80,7 +58,8 @@ export function deployApp({
env: {
...environment.envVars,
SENTRY: sentry.enabled ? '1' : '0',
GRAPHQL_ENDPOINT: serviceLocalEndpoint(graphql.service).apply(s => `${s}/graphql`),
GRAPHQL_PUBLIC_ENDPOINT: `https://${environment.appDns}/graphql`,
GRAPHQL_PUBLIC_ORIGIN: `https://${environment.appDns}`,
SERVER_ENDPOINT: serviceLocalEndpoint(graphql.service),
APP_BASE_URL: `https://${environment.appDns}/`,
INTEGRATION_SLACK: '1',
@ -89,8 +68,6 @@ export function deployApp({
DOCS_URL: 'https://the-guild.dev/graphql/hive/docs',
GRAPHQL_PERSISTED_OPERATIONS: '1',
ZENDESK_SUPPORT: zendesk.enabled ? '1' : '0',
SUPERTOKENS_CONNECTION_URI: supertokens.localEndpoint,
EMAILS_ENDPOINT: serviceLocalEndpoint(emails.service),
AUTH_GITHUB: '1',
AUTH_GOOGLE: '1',
AUTH_REQUIRE_EMAIL_VERIFICATION: '1',
@ -104,11 +81,6 @@ export function deployApp({
.withSecret('INTEGRATION_SLACK_CLIENT_ID', slackApp.secret, 'clientId')
.withSecret('INTEGRATION_SLACK_CLIENT_SECRET', slackApp.secret, 'clientSecret')
.withSecret('STRIPE_PUBLIC_KEY', billing.secret, 'stripePublicKey')
.withSecret('SUPERTOKENS_API_KEY', supertokens.secret, 'apiKey')
.withSecret('AUTH_GITHUB_CLIENT_ID', githubOAuthSecret, 'clientId')
.withSecret('AUTH_GITHUB_CLIENT_SECRET', githubOAuthSecret, 'clientSecret')
.withSecret('AUTH_GOOGLE_CLIENT_ID', googleOAuthSecret, 'clientId')
.withSecret('AUTH_GOOGLE_CLIENT_SECRET', googleOAuthSecret, 'clientSecret')
.withConditionalSecret(sentry.enabled, 'SENTRY_DSN', sentry.secret, 'dsn')
.deploy();
}

View file

@ -1,5 +1,6 @@
import * as pulumi from '@pulumi/pulumi';
import { serviceLocalEndpoint } from '../utils/local-endpoint';
import { ServiceSecret } from '../utils/secrets';
import { ServiceDeployment } from '../utils/service-deployment';
import { StripeBillingService } from './billing';
import { CDN } from './cf-cdn';
@ -25,6 +26,11 @@ import { Zendesk } from './zendesk';
export type GraphQL = ReturnType<typeof deployGraphQL>;
class AppOAuthSecret extends ServiceSecret<{
clientId: string | pulumi.Output<string>;
clientSecret: string | pulumi.Output<string>;
}> {}
export function deployGraphQL({
clickhouse,
image,
@ -75,6 +81,16 @@ export function deployGraphQL({
const apiConfig = new pulumi.Config('api');
const apiEnv = apiConfig.requireObject<Record<string, string>>('env');
const oauthConfig = new pulumi.Config('oauth');
const githubOAuthSecret = new AppOAuthSecret('oauth-github', {
clientId: oauthConfig.requireSecret('githubClient'),
clientSecret: oauthConfig.requireSecret('githubSecret'),
});
const googleOAuthSecret = new AppOAuthSecret('oauth-google', {
clientId: oauthConfig.requireSecret('googleClient'),
clientSecret: oauthConfig.requireSecret('googleSecret'),
});
return (
new ServiceDeployment(
'graphql-api',
@ -108,6 +124,7 @@ export function deployGraphQL({
EMAILS_ENDPOINT: serviceLocalEndpoint(emails.service),
USAGE_ESTIMATOR_ENDPOINT: serviceLocalEndpoint(usageEstimator.service),
WEB_APP_URL: `https://${environment.appDns}`,
GRAPHQL_PUBLIC_ORIGIN: `https://${environment.appDns}`,
CDN_CF: '1',
HIVE: '1',
HIVE_REPORTING: '1',
@ -115,9 +132,13 @@ export function deployGraphQL({
HIVE_REPORTING_ENDPOINT: 'http://0.0.0.0:4000/graphql',
ZENDESK_SUPPORT: zendesk.enabled ? '1' : '0',
INTEGRATION_GITHUB: '1',
SUPERTOKENS_CONNECTION_URI: supertokens.localEndpoint,
AUTH_ORGANIZATION_OIDC: '1',
GRAPHQL_PERSISTED_OPERATIONS_PATH: './persisted-operations.json',
// Auth
SUPERTOKENS_CONNECTION_URI: supertokens.localEndpoint,
AUTH_GITHUB: '1',
AUTH_GOOGLE: '1',
AUTH_ORGANIZATION_OIDC: '1',
AUTH_REQUIRE_EMAIL_VERIFICATION: '1',
},
exposesMetrics: true,
port: 4000,
@ -160,8 +181,12 @@ export function deployGraphQL({
.withSecret('S3_SECRET_ACCESS_KEY', s3.secret, 'secretAccessKey')
.withSecret('S3_BUCKET_NAME', s3.secret, 'bucket')
.withSecret('S3_ENDPOINT', s3.secret, 'endpoint')
// Supertokens
// Auth
.withSecret('SUPERTOKENS_API_KEY', supertokens.secret, 'apiKey')
.withSecret('AUTH_GITHUB_CLIENT_ID', githubOAuthSecret, 'clientId')
.withSecret('AUTH_GITHUB_CLIENT_SECRET', githubOAuthSecret, 'clientSecret')
.withSecret('AUTH_GOOGLE_CLIENT_ID', googleOAuthSecret, 'clientId')
.withSecret('AUTH_GOOGLE_CLIENT_SECRET', googleOAuthSecret, 'clientSecret')
// Zendesk
.withConditionalSecret(zendesk.enabled, 'ZENDESK_SUBDOMAIN', zendesk.secret, 'subdomain')
.withConditionalSecret(zendesk.enabled, 'ZENDESK_USERNAME', zendesk.secret, 'username')

View file

@ -66,6 +66,14 @@ export function deployProxy({
timeoutInSeconds: 60,
retriable: true,
},
{
name: 'auth',
path: '/auth-api',
customRewrite: '/auth-api',
service: graphql.service,
timeoutInSeconds: 60,
retriable: true,
},
{
name: 'usage',
path: '/usage',

View file

@ -243,8 +243,6 @@ services:
ENCRYPTION_SECRET: '${HIVE_ENCRYPTION_SECRET}'
WEB_APP_URL: '${HIVE_APP_BASE_URL}'
PORT: 3001
SUPERTOKENS_CONNECTION_URI: http://supertokens:3567
SUPERTOKENS_API_KEY: '${SUPERTOKENS_API_KEY}'
S3_ENDPOINT: 'http://s3:9000'
S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
S3_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
@ -253,8 +251,13 @@ services:
CDN_AUTH_PRIVATE_KEY: ${CDN_AUTH_PRIVATE_KEY}
CDN_API: '1'
CDN_API_BASE_URL: 'http://localhost:8082'
AUTH_ORGANIZATION_OIDC: '1'
LOG_LEVEL: '${LOG_LEVEL:-info}'
# Auth
AUTH_ORGANIZATION_OIDC: '1'
AUTH_REQUIRE_EMAIL_VERIFICATION: '0'
SUPERTOKENS_CONNECTION_URI: http://supertokens:3567
SUPERTOKENS_API_KEY: '${SUPERTOKENS_API_KEY}'
GRAPHQL_PUBLIC_ORIGIN: http://localhost:8082
policy:
image: '${DOCKER_REGISTRY}policy${DOCKER_TAG}'
@ -390,11 +393,8 @@ services:
PORT: 3000
NODE_ENV: production
APP_BASE_URL: '${HIVE_APP_BASE_URL}'
SUPERTOKENS_CONNECTION_URI: http://supertokens:3567
SUPERTOKENS_API_KEY: '${SUPERTOKENS_API_KEY}'
EMAILS_ENDPOINT: http://emails:3011
GRAPHQL_ENDPOINT: http://server:3001/graphql
SERVER_ENDPOINT: http://server:3001
GRAPHQL_PUBLIC_ENDPOINT: http://localhost:8082/graphql
GRAPHQL_PUBLIC_ORIGIN: http://localhost:8082
AUTH_REQUIRE_EMAIL_VERIFICATION: '0'
AUTH_ORGANIZATION_OIDC: '1'
LOG_LEVEL: '${LOG_LEVEL:-info}'

View file

@ -169,12 +169,18 @@ services:
GITHUB_APP_PRIVATE_KEY: 5f938d51a065476c4dc1b04aeba13afb
FEEDBACK_SLACK_TOKEN: ''
FEEDBACK_SLACK_CHANNEL: '#hive'
AUTH_ORGANIZATION_OIDC: '1'
S3_PUBLIC_URL: 'http://localhost:8083'
USAGE_ESTIMATOR_ENDPOINT: '${USAGE_ESTIMATOR_ENDPOINT}'
RATE_LIMIT_ENDPOINT: '${RATE_LIMIT_ENDPOINT}'
EMAIL_PROVIDER: '${EMAIL_PROVIDER}'
LOG_LEVEL: debug
# Auth
WEB_APP_URL: '${HIVE_APP_BASE_URL}'
AUTH_ORGANIZATION_OIDC: '1'
AUTH_REQUIRE_EMAIL_VERIFICATION: '0'
SUPERTOKENS_CONNECTION_URI: http://supertokens:3567
SUPERTOKENS_API_KEY: '${SUPERTOKENS_API_KEY}'
GRAPHQL_PUBLIC_ORIGIN: http://localhost:8082
broker:
image: vectorized/redpanda:latest

View file

@ -188,7 +188,7 @@ export class AuthManager {
getCurrentUser: () => Promise<(User & { isAdmin: boolean }) | never> = share(async () => {
if (!this.session) {
throw new AccessError('Authorization token is missing');
throw new AccessError('Authorization token is missing', 'UNAUTHENTICATED');
}
const user = await this.storage.getUserBySuperTokenId({

View file

@ -55,11 +55,16 @@ export class GitHubIntegrationManager {
installationId: string;
},
): Promise<void> {
this.logger.debug('Registering GitHub integration (organization=%s)', input.organization);
this.logger.debug(
'Registering GitHub integration (organization=%s, installationId:%s)',
input.organization,
input.installationId,
);
await this.authManager.ensureOrganizationAccess({
...input,
scope: OrganizationAccessScope.INTEGRATIONS,
});
this.logger.debug('Updating organization');
await this.storage.addGitHubIntegration({
organization: input.organization,
installationId: input.installationId,
@ -72,6 +77,7 @@ export class GitHubIntegrationManager {
...input,
scope: OrganizationAccessScope.INTEGRATIONS,
});
this.logger.debug('Updating organization');
await this.storage.deleteGitHubIntegration({
organization: input.organization,
});
@ -80,7 +86,8 @@ export class GitHubIntegrationManager {
async isAvailable(selector: OrganizationSelector): Promise<boolean> {
this.logger.debug('Checking GitHub integration (organization=%s)', selector.organization);
if (this.isEnabled()) {
if (!this.isEnabled()) {
this.logger.debug('GitHub integration is disabled.');
return false;
}

View file

@ -42,6 +42,7 @@ export class SlackIntegrationManager {
...input,
scope: OrganizationAccessScope.INTEGRATIONS,
});
this.logger.debug('Updating organization');
await this.storage.addSlackIntegration({
organization: input.organization,
token: this.crypto.encrypt(input.token),
@ -54,6 +55,7 @@ export class SlackIntegrationManager {
...input,
scope: OrganizationAccessScope.INTEGRATIONS,
});
this.logger.debug('Updating organization');
await this.storage.deleteSlackIntegration({
organization: input.organization,
});

View file

@ -278,6 +278,31 @@ export class OIDCIntegrationsProvider {
organizationId: integration.linkedOrganizationId,
} as const;
}
async getOIDCIntegrationById(args: { oidcIntegrationId: string }) {
if (this.isEnabled() === false) {
return {
type: 'error',
message: 'OIDC integrations are disabled.',
} as const;
}
const integration = await this.storage.getOIDCIntegrationById({
oidcIntegrationId: args.oidcIntegrationId,
});
if (integration === null) {
return {
type: 'error',
message: 'Integration not found.',
} as const;
}
return {
type: 'ok',
organizationId: integration.linkedOrganizationId,
} as const;
}
}
const OIDCIntegrationClientIdModel = zod

View file

@ -8,6 +8,7 @@ export default gql`
organizationTransferRequest(
selector: OrganizationTransferRequestSelector!
): OrganizationTransfer
myDefaultOrganization(previouslyVisitedOrganizationId: ID): OrganizationPayload
}
extend type Mutation {

View file

@ -9,6 +9,7 @@ import {
} from '../auth/providers/organization-access';
import { isProjectScope, ProjectAccessScope } from '../auth/providers/project-access';
import { isTargetScope, TargetAccessScope } from '../auth/providers/target-access';
import { OIDCIntegrationsProvider } from '../oidc-integrations/providers/oidc-integrations.provider';
import { InMemoryRateLimiter } from '../rate-limit/providers/in-memory-rate-limiter';
import { IdTranslator } from '../shared/providers/id-translator';
import { Logger } from '../shared/providers/logger';
@ -54,6 +55,71 @@ export const resolvers: OrganizationModule.Resolvers = {
async organizations(_, __, { injector }) {
return injector.get(OrganizationManager).getOrganizations();
},
async myDefaultOrganization(_, { previouslyVisitedOrganizationId }, { injector }) {
const user = await injector.get(AuthManager).getCurrentUser();
const organizationManager = injector.get(OrganizationManager);
// For an OIDC Integration User we want to return the linked organization
if (user?.oidcIntegrationId) {
const oidcIntegration = await injector
.get(OIDCIntegrationsProvider)
.getOIDCIntegrationById({
oidcIntegrationId: user.oidcIntegrationId,
});
if (oidcIntegration.type === 'ok') {
const org = await organizationManager.getOrganization({
organization: oidcIntegration.organizationId,
});
return {
selector: {
organization: org.cleanId,
},
organization: org,
};
}
return null;
}
// This is the organization that got stored as an cookie
// We make sure it actually exists before directing to it.
if (previouslyVisitedOrganizationId) {
const orgId = await injector.get(IdTranslator).translateOrganizationId({
organization: previouslyVisitedOrganizationId,
});
const org = await organizationManager.getOrganization({
organization: orgId,
});
if (org) {
return {
selector: {
organization: org.cleanId,
},
organization: org,
};
}
}
if (user?.id) {
const allOrganizations = await organizationManager.getOrganizations();
if (allOrganizations.length > 0) {
const firstOrg = allOrganizations[0];
return {
selector: {
organization: firstOrg.cleanId,
},
organization: firstOrg,
};
}
}
return null;
},
async organizationByInviteCode(_, { code }, { injector }) {
const organization = await injector.get(OrganizationManager).getOrganizationByInviteCode({
code,

View file

@ -27,7 +27,11 @@ export function isGraphQLError(error: unknown): error is GraphQLError {
export const HiveError = GraphQLError;
export class AccessError extends HiveError {
constructor(reason: string) {
super(`No access (reason: "${reason}")`);
constructor(reason: string, code: string = 'UNAUTHORISED') {
super(`No access (reason: "${reason}")`, {
extensions: {
code,
},
});
}
}

View file

@ -4,7 +4,7 @@
"license": "MIT",
"private": true,
"scripts": {
"build": "BUILD=1 tsx ../../../../scripts/runify.ts",
"build": "tsx ../../../../scripts/runify.ts",
"dev": "tsup-node --config ../../../configs/tsup/dev.config.node.ts src/index.ts"
},
"devDependencies": {

View file

@ -57,3 +57,23 @@ SUPERTOKENS_API_KEY=bubatzbieber6942096420
# Organization level Open ID Connect Authentication
AUTH_ORGANIZATION_OIDC=0
## Enable GitHub login
AUTH_GITHUB="<sync>"
AUTH_GITHUB_CLIENT_ID="<sync>"
AUTH_GITHUB_CLIENT_SECRET="<sync>"
## Enable Google login
AUTH_GOOGLE="<sync>"
AUTH_GOOGLE_CLIENT_ID="<sync>"
AUTH_GOOGLE_CLIENT_SECRET="<sync>"
WEB_APP_URL="http://localhost:3000"
GRAPHQL_PUBLIC_ORIGIN="http://localhost:3001"
## Enable Okta login
AUTH_OKTA=0
AUTH_OKTA_HIDDEN=0
AUTH_OKTA_ENDPOINT="<sync>"
AUTH_OKTA_CLIENT_ID="<sync>"
AUTH_OKTA_CLIENT_SECRET="<sync>"

View file

@ -9,6 +9,7 @@ The GraphQL API for GraphQL Hive.
| `PORT` | **Yes** | The port this service is running on. | `4013` |
| `ENCRYPTION_SECRET` | **Yes** | Secret for encrypting stuff. | `8ebe95cg21c1fee355e9fa32c8c33141` |
| `WEB_APP_URL` | **Yes** | The url of the web app. | `http://127.0.0.1:3000` |
| `GRAPHQL_PUBLIC_ORIGIN` | **Yes** | The origin of the GraphQL server. | `http://127.0.0.1:4013` |
| `RATE_LIMIT_ENDPOINT` | **Yes** | The endpoint of the rate limiting service. | `http://127.0.0.1:4012` |
| `EMAILS_ENDPOINT` | **Yes** | The endpoint of the GraphQL Hive Email service. | `http://127.0.0.1:6260` |
| `TOKENS_ENDPOINT` | **Yes** | The endpoint of the tokens service. | `http://127.0.0.1:6001` |
@ -40,10 +41,22 @@ The GraphQL API for GraphQL Hive.
| `CDN_API_BASE_URL` | No (Yes if `CDN_API` is set to `1`) | The public base url of the API service. | `http://localhost:8082` |
| `SUPERTOKENS_CONNECTION_URI` | **Yes** | The URI of the SuperTokens instance. | `http://127.0.0.1:3567` |
| `SUPERTOKENS_API_KEY` | **Yes** | The API KEY of the SuperTokens instance. | `iliketurtlesandicannotlie` |
| `AUTH_GITHUB` | No | Whether login via GitHub should be allowed | `1` (enabled) or `0` (disabled) |
| `AUTH_GITHUB_CLIENT_ID` | No (**Yes** if `AUTH_GITHUB` is set) | The GitHub client ID. | `g6aff8102efda5e1d12e` |
| `AUTH_GITHUB_CLIENT_SECRET` | No (**Yes** if `AUTH_GITHUB` is set) | The GitHub client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` |
| `AUTH_GOOGLE` | No | Whether login via Google should be allowed | `1` (enabled) or `0` (disabled) |
| `AUTH_GOOGLE_CLIENT_ID` | No (**Yes** if `AUTH_GOOGLE` is set) | The Google client ID. | `g6aff8102efda5e1d12e` |
| `AUTH_GOOGLE_CLIENT_SECRET` | No (**Yes** if `AUTH_GOOGLE` is set) | The Google client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` |
| `AUTH_ORGANIZATION_OIDC` | No | Whether linking a Hive organization to an Open ID Connect provider is allowed. (Default: `0`) | `1` (enabled) or `0` (disabled) |
| `AUTH_OKTA` | No | Whether login via Okta should be allowed | `1` (enabled) or `0` (disabled) |
| `AUTH_OKTA_CLIENT_ENDPOINT` | No (**Yes** if `AUTH_OKTA` is set) | The Okta endpoint. | `https://dev-1234567.okta.com` |
| `AUTH_OKTA_HIDDEN` | No | Whether the Okta login button should be hidden. (Default: `0`) | `1` (enabled) or `0` (disabled) |
| `AUTH_OKTA_CLIENT_ID` | No (**Yes** if `AUTH_OKTA` is set) | The Okta client ID. | `g6aff8102efda5e1d12e` |
| `AUTH_OKTA_CLIENT_SECRET` | No (**Yes** if `AUTH_OKTA` is set) | The Okta client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` |
| `AUTH_REQUIRE_EMAIL_VERIFICATION` | No | Whether verifying the email address is mandatory. | `1` (enabled) or `0` (disabled) |
| `INTEGRATION_GITHUB` | No | Whether the GitHub integration is enabled | `1` (enabled) or `0` (disabled) |
| `INTEGRATION_GITHUB_GITHUB_APP_ID` | No (Yes if `INTEGRATION_GITHUB` is set to `1`) | The GitHub app id. | `123` |
| `INTEGRATION_GITHUB_GITHUB_APP_PRIVATE_KEY` | No (Yes if `INTEGRATION_GITHUB` is set to `1`) | The GitHub app private key. | `letmein1` |
| `AUTH_ORGANIZATION_OIDC` | No | Whether linking a Hive organization to an Open ID Connect provider is allowed. (Default: `0`) | `1` (enabled) or `0` (disabled) |
| `ENVIRONMENT` | No | The environment of your Hive app. (**Note:** This will be used for Sentry reporting.) | `staging` |
| `SENTRY` | No | Whether Sentry error reporting should be enabled. | `1` (enabled) or `0` (disabled) |
| `SENTRY_DSN` | No | The DSN for reporting errors to Sentry. | `https://dooobars@o557896.ingest.sentry.io/12121212` |

View file

@ -20,6 +20,8 @@
"@escape.tech/graphql-armor-max-depth": "2.2.0",
"@escape.tech/graphql-armor-max-directives": "2.1.0",
"@escape.tech/graphql-armor-max-tokens": "2.3.0",
"@fastify/cors": "9.0.1",
"@fastify/formbody": "7.4.0",
"@graphql-hive/client": "workspace:*",
"@graphql-yoga/plugin-persisted-operations": "3.2.0",
"@graphql-yoga/plugin-response-cache": "3.4.0",
@ -30,6 +32,7 @@
"@sentry/integrations": "7.108.0",
"@sentry/node": "7.108.0",
"@swc/core": "1.4.8",
"@trpc/client": "10.45.2",
"@trpc/server": "10.45.2",
"@whatwg-node/server": "0.9.32",
"dotenv": "16.4.5",
@ -41,6 +44,8 @@
"pino-pretty": "11.0.0",
"prom-client": "15.1.0",
"reflect-metadata": "0.2.1",
"supertokens-js-override": "0.0.4",
"supertokens-node": "15.2.1",
"tslib": "2.6.2",
"zod": "3.22.4"
},

View file

@ -2,23 +2,19 @@ import { CryptoProvider } from 'packages/services/api/src/modules/shared/provide
import { z } from 'zod';
import type { Storage } from '@hive/api';
import { OrganizationAccessScope, ProjectAccessScope, TargetAccessScope } from '@hive/api';
import { FastifyRequest, handleTRPCError } from '@hive/service-common';
import type { inferAsyncReturnType } from '@trpc/server';
import { initTRPC } from '@trpc/server';
export async function createContext({
storage,
crypto,
req,
}: {
storage: Storage;
crypto: CryptoProvider;
req: FastifyRequest;
}) {
return {
storage,
crypto,
req,
};
}
@ -32,10 +28,9 @@ const oidcDefaultScopes = [
];
const t = initTRPC.context<Context>().create();
const procedure = t.procedure.use(handleTRPCError);
export const internalApiRouter = t.router({
ensureUser: procedure
ensureUser: t.procedure
.input(
z
.object({
@ -58,64 +53,8 @@ export const internalApiRouter = t.router({
return result;
}),
getDefaultOrgForUser: procedure
.input(
z.object({
superTokensUserId: z.string(),
lastOrgId: z.union([z.string(), z.null()]),
}),
)
.query(async ({ input, ctx }) => {
const user = await ctx.storage.getUserBySuperTokenId({
superTokensUserId: input.superTokensUserId,
});
// For an OIDC Integration User we want to return the linked organization
if (user?.oidcIntegrationId) {
const oidcIntegration = await ctx.storage.getOIDCIntegrationById({
oidcIntegrationId: user.oidcIntegrationId,
});
if (oidcIntegration) {
const org = await ctx.storage.getOrganization({
organization: oidcIntegration.linkedOrganizationId,
});
return {
id: org.id,
cleanId: org.cleanId,
};
}
}
// This is the organizaton that got stored as an cookie
// We make sure it actually exists before directing to it.
if (input.lastOrgId) {
const org = await ctx.storage.getOrganizationByCleanId({ cleanId: input.lastOrgId });
if (org) {
return {
id: org.id,
cleanId: org.cleanId,
};
}
}
if (user?.id) {
const allAllOraganizations = await ctx.storage.getOrganizations({ user: user.id });
if (allAllOraganizations.length > 0) {
const someOrg = allAllOraganizations[0];
return {
id: someOrg.id,
cleanId: someOrg.cleanId,
};
}
}
return null;
}),
getOIDCIntegrationById: procedure
getOIDCIntegrationById: t.procedure
.input(
z.object({
oidcIntegrationId: z.string().min(1),
@ -145,4 +84,6 @@ export const internalApiRouter = t.router({
}),
});
export const createInternalApiCaller = t.createCallerFactory(internalApiRouter);
export type InternalApi = typeof internalApiRouter;

View file

@ -22,7 +22,13 @@ const EnvironmentModel = zod.object({
ENVIRONMENT: emptyString(zod.string().optional()),
RELEASE: emptyString(zod.string().optional()),
ENCRYPTION_SECRET: emptyString(zod.string()),
WEB_APP_URL: emptyString(zod.string().url().optional()),
WEB_APP_URL: zod.string().url(),
GRAPHQL_PUBLIC_ORIGIN: zod
.string({
required_error:
'GRAPHQL_PUBLIC_ORIGIN is required (see: https://github.com/kamilkisiela/graphql-hive/pull/4288#issue-2195509699)',
})
.url(),
RATE_LIMIT_ENDPOINT: emptyString(zod.string().url().optional()),
SCHEMA_POLICY_ENDPOINT: emptyString(zod.string().url().optional()),
TOKENS_ENDPOINT: zod.string().url(),
@ -34,6 +40,9 @@ const EnvironmentModel = zod.object({
SCHEMA_ENDPOINT: zod.string().url(),
AUTH_ORGANIZATION_OIDC: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
GRAPHQL_PERSISTED_OPERATIONS_PATH: emptyString(zod.string().optional()),
AUTH_REQUIRE_EMAIL_VERIFICATION: emptyString(
zod.union([zod.literal('1'), zod.literal('0')]).optional(),
),
});
const SentryModel = zod.union([
@ -146,6 +155,45 @@ const S3Model = zod.object({
S3_PUBLIC_URL: emptyString(zod.string().url().optional()),
});
const AuthGitHubConfigSchema = zod.union([
zod.object({
AUTH_GITHUB: zod.union([zod.void(), zod.literal('0'), zod.literal('')]),
}),
zod.object({
AUTH_GITHUB: zod.literal('1'),
AUTH_GITHUB_CLIENT_ID: zod.string(),
AUTH_GITHUB_CLIENT_SECRET: zod.string(),
}),
]);
const AuthGoogleConfigSchema = zod.union([
zod.object({
AUTH_GOOGLE: zod.union([zod.void(), zod.literal('0'), zod.literal('')]),
}),
zod.object({
AUTH_GOOGLE: zod.literal('1'),
AUTH_GOOGLE_CLIENT_ID: zod.string(),
AUTH_GOOGLE_CLIENT_SECRET: zod.string(),
}),
]);
const AuthOktaConfigSchema = zod.union([
zod.object({
AUTH_OKTA: zod.union([zod.void(), zod.literal('0')]),
}),
zod.object({
AUTH_OKTA: zod.literal('1'),
AUTH_OKTA_HIDDEN: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
AUTH_OKTA_ENDPOINT: zod.string().url(),
AUTH_OKTA_CLIENT_ID: zod.string(),
AUTH_OKTA_CLIENT_SECRET: zod.string(),
}),
]);
const AuthOktaMultiTenantSchema = zod.object({
AUTH_ORGANIZATION_OIDC: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
});
const LogModel = zod.object({
LOG_LEVEL: emptyString(
zod
@ -165,35 +213,28 @@ const LogModel = zod.object({
),
});
// eslint-disable-next-line no-process-env
const processEnv = process.env;
const configs = {
// eslint-disable-next-line no-process-env
base: EnvironmentModel.safeParse(process.env),
// eslint-disable-next-line no-process-env
sentry: SentryModel.safeParse(process.env),
// eslint-disable-next-line no-process-env
postgres: PostgresModel.safeParse(process.env),
// eslint-disable-next-line no-process-env
clickhouse: ClickHouseModel.safeParse(process.env),
// eslint-disable-next-line no-process-env
redis: RedisModel.safeParse(process.env),
// eslint-disable-next-line no-process-env
supertokens: SuperTokensModel.safeParse(process.env),
// eslint-disable-next-line no-process-env
github: GitHubModel.safeParse(process.env),
// eslint-disable-next-line no-process-env
cdnCf: CdnCFModel.safeParse(process.env),
// eslint-disable-next-line no-process-env
cdnApi: CdnApiModel.safeParse(process.env),
// eslint-disable-next-line no-process-env
prometheus: PrometheusModel.safeParse(process.env),
// eslint-disable-next-line no-process-env
hive: HiveModel.safeParse(process.env),
// eslint-disable-next-line no-process-env
s3: S3Model.safeParse(process.env),
// eslint-disable-next-line no-process-env
log: LogModel.safeParse(process.env),
// eslint-disable-next-line no-process-env
zendeskSupport: ZendeskSupportModel.safeParse(process.env),
base: EnvironmentModel.safeParse(processEnv),
sentry: SentryModel.safeParse(processEnv),
postgres: PostgresModel.safeParse(processEnv),
clickhouse: ClickHouseModel.safeParse(processEnv),
redis: RedisModel.safeParse(processEnv),
supertokens: SuperTokensModel.safeParse(processEnv),
authGithub: AuthGitHubConfigSchema.safeParse(processEnv),
authGoogle: AuthGoogleConfigSchema.safeParse(processEnv),
authOkta: AuthOktaConfigSchema.safeParse(processEnv),
authOktaMultiTenant: AuthOktaMultiTenantSchema.safeParse(processEnv),
github: GitHubModel.safeParse(processEnv),
cdnCf: CdnCFModel.safeParse(processEnv),
cdnApi: CdnApiModel.safeParse(processEnv),
prometheus: PrometheusModel.safeParse(processEnv),
hive: HiveModel.safeParse(processEnv),
s3: S3Model.safeParse(processEnv),
log: LogModel.safeParse(processEnv),
zendeskSupport: ZendeskSupportModel.safeParse(processEnv),
};
const environmentErrors: Array<string> = [];
@ -223,6 +264,10 @@ const sentry = extractConfig(configs.sentry);
const clickhouse = extractConfig(configs.clickhouse);
const redis = extractConfig(configs.redis);
const supertokens = extractConfig(configs.supertokens);
const authGithub = extractConfig(configs.authGithub);
const authGoogle = extractConfig(configs.authGoogle);
const authOkta = extractConfig(configs.authOkta);
const authOktaMultiTenant = extractConfig(configs.authOktaMultiTenant);
const github = extractConfig(configs.github);
const prometheus = extractConfig(configs.prometheus);
const log = extractConfig(configs.log);
@ -254,11 +299,9 @@ export const env = {
release: base.RELEASE ?? 'local',
encryptionSecret: base.ENCRYPTION_SECRET,
hiveServices: {
webApp: base.WEB_APP_URL
? {
url: base.WEB_APP_URL,
}
: null,
webApp: {
url: base.WEB_APP_URL,
},
tokens: {
endpoint: base.TOKENS_ENDPOINT,
},
@ -311,6 +354,33 @@ export const env = {
connectionURI: supertokens.SUPERTOKENS_CONNECTION_URI,
apiKey: supertokens.SUPERTOKENS_API_KEY,
},
auth: {
github:
authGithub.AUTH_GITHUB === '1'
? {
clientId: authGithub.AUTH_GITHUB_CLIENT_ID,
clientSecret: authGithub.AUTH_GITHUB_CLIENT_SECRET,
}
: null,
google:
authGoogle.AUTH_GOOGLE === '1'
? {
clientId: authGoogle.AUTH_GOOGLE_CLIENT_ID,
clientSecret: authGoogle.AUTH_GOOGLE_CLIENT_SECRET,
}
: null,
okta:
authOkta.AUTH_OKTA === '1'
? {
endpoint: authOkta.AUTH_OKTA_ENDPOINT,
hidden: authOkta.AUTH_OKTA_HIDDEN === '1',
clientId: authOkta.AUTH_OKTA_CLIENT_ID,
clientSecret: authOkta.AUTH_OKTA_CLIENT_SECRET,
}
: null,
organizationOIDC: authOktaMultiTenant.AUTH_ORGANIZATION_OIDC === '1',
requireEmailVerification: base.AUTH_REQUIRE_EMAIL_VERIFICATION === '1',
},
github:
github.INTEGRATION_GITHUB === '1'
? {
@ -357,6 +427,7 @@ export const env = {
hive: hiveConfig,
graphql: {
persistedOperationsPath: base.GRAPHQL_PERSISTED_OPERATIONS_PATH ?? null,
origin: base.GRAPHQL_PUBLIC_ORIGIN,
},
zendeskSupport:
zendeskSupport.ZENDESK_SUPPORT === '1'

View file

@ -12,7 +12,6 @@ import {
} from 'graphql';
import { createYoga, Plugin, useErrorHandler } from 'graphql-yoga';
import hyperid from 'hyperid';
import zod from 'zod';
import { isGraphQLError } from '@envelop/core';
import { useGenericAuth } from '@envelop/generic-auth';
import { useGraphQlJit } from '@envelop/graphql-jit';
@ -21,11 +20,12 @@ import { useSentry } from '@envelop/sentry';
import { useYogaHive } from '@graphql-hive/client';
import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations';
import { useResponseCache } from '@graphql-yoga/plugin-response-cache';
import { HiveError, Registry, RegistryContext } from '@hive/api';
import { Registry, RegistryContext } from '@hive/api';
import { cleanRequestId } from '@hive/service-common';
import { runWithAsyncContext } from '@sentry/node';
import { asyncStorage } from './async-storage';
import type { HiveConfig } from './environment';
import { resolveUser, type SupertokensSession } from './supertokens';
import { useArmor } from './use-armor';
import { extractUserId, useSentryUser } from './use-sentry-user';
@ -37,23 +37,6 @@ function hashSessionId(sessionId: string): string {
return createHash('sha256').update(sessionId).digest('hex');
}
const SuperTokenAccessTokenModel = zod.object({
version: zod.literal('1'),
superTokensUserId: zod.string(),
/**
* Supertokens for some reason omits externalUserId from the access token payload if it is null.
*/
externalUserId: zod.optional(zod.union([zod.string(), zod.null()])),
email: zod.string(),
});
const SuperTokenSessionVerifyModel = zod.object({
status: zod.literal('OK'),
session: zod.object({
userDataInJWT: SuperTokenAccessTokenModel,
}),
});
export interface GraphQLHandlerOptions {
graphiqlEndpoint: string;
registry: Registry;
@ -69,12 +52,10 @@ export interface GraphQLHandlerOptions {
persistedOperations: Record<string, DocumentNode | string> | null;
}
export type SuperTokenSessionPayload = zod.TypeOf<typeof SuperTokenAccessTokenModel>;
interface Context extends RegistryContext {
req: FastifyRequest;
reply: FastifyReply;
session: SuperTokenSessionPayload | null;
session: SupertokensSession | null;
}
const NoIntrospection: ValidationRule = (context: ValidationContext) => ({
@ -204,27 +185,8 @@ export const graphqlHandler = (options: GraphQLHandlerOptions): RouteHandlerMeth
useGenericAuth({
mode: 'resolve-only',
contextFieldName: 'session',
resolveUserFn: async (ctx: Context) => {
if (ctx.headers['authorization']) {
let authHeader = ctx.headers['authorization'];
authHeader = Array.isArray(authHeader) ? authHeader[0] : authHeader;
const authHeaderParts = authHeader.split(' ');
if (authHeaderParts.length === 2 && authHeaderParts[0] === 'Bearer') {
const accessToken = authHeaderParts[1];
// The token issued by Hive is always 32 characters long.
// Everything longer should be treated as a supertokens token (JWT).
if (accessToken.length > 32) {
return await verifySuperTokensSession(
options.supertokens.connectionUri,
options.supertokens.apiKey,
accessToken,
);
}
}
}
return null;
async resolveUserFn(ctx: Context) {
return resolveUser(ctx);
},
}),
useYogaHive({
@ -358,52 +320,6 @@ export const graphqlHandler = (options: GraphQLHandlerOptions): RouteHandlerMeth
};
};
/**
* Verify whether a SuperTokens access token session is valid.
* https://app.swaggerhub.com/apis/supertokens/CDI/2.20#/Session%20Recipe/verifySession
*/
async function verifySuperTokensSession(
connectionUri: string,
apiKey: string,
accessToken: string,
): Promise<SuperTokenSessionPayload> {
const response = await fetch(connectionUri + '/appid-public/public/recipe/session/verify', {
method: 'POST',
headers: {
'content-type': 'application/json',
'api-key': apiKey,
rid: 'session',
'cdi-version': '4.0',
},
body: JSON.stringify({
accessToken,
enableAntiCsrf: false,
doAntiCsrfCheck: false,
checkDatabase: true,
}),
});
const body = await response.text();
if (response.status !== 200) {
console.error(
`SuperTokens session verification failed with status ${response.status}.\n` + body,
);
throw new Error(`SuperTokens instance returned an unexpected error.`);
}
const result = SuperTokenSessionVerifyModel.safeParse(JSON.parse(body));
if (result.success === false) {
console.error(`SuperTokens session verification failed.\n` + body);
throw new HiveError(`Invalid token provided`);
}
// ensure externalUserId is a string or null
return {
...result.data.session.userDataInJWT,
externalUserId: result.data.session.userDataInJWT.externalUserId ?? null,
};
}
function isOperationDefinitionNode(def: DefinitionNode): def is OperationDefinitionNode {
return def.kind === Kind.OPERATION_DEFINITION;
}

View file

@ -2,8 +2,16 @@
import * as fs from 'fs';
import got from 'got';
import { DocumentNode, GraphQLError, stripIgnoredCharacters } from 'graphql';
import supertokens from 'supertokens-node';
import {
errorHandler as supertokensErrorHandler,
plugin as supertokensFastifyPlugin,
} from 'supertokens-node/framework/fastify/index.js';
import cors from '@fastify/cors';
import type { FastifyCorsOptionsDelegateCallback } from '@fastify/cors';
import 'reflect-metadata';
import { hostname } from 'os';
import formDataPlugin from '@fastify/formbody';
import { createRegistry, createTaskRunner, CryptoProvider, LogFn, Logger } from '@hive/api';
import { createArtifactRequestHandler } from '@hive/cdn-script/artifact-handler';
import { ArtifactStorageReader } from '@hive/cdn-script/artifact-storage-reader';
@ -36,6 +44,7 @@ import { asyncStorage } from './async-storage';
import { env } from './environment';
import { graphqlHandler } from './graphql-handler';
import { clickHouseElapsedDuration, clickHouseReadDuration } from './metrics';
import { initSupertokens } from './supertokens';
export async function main() {
init({
@ -69,6 +78,7 @@ export async function main() {
const server = await createServer({
name: 'graphql-api',
tracing: true,
cors: false,
log: {
level: env.log.level,
requests: env.log.requests,
@ -93,6 +103,31 @@ export async function main() {
},
);
server.setErrorHandler(supertokensErrorHandler());
await server.register(cors, (_: unknown): FastifyCorsOptionsDelegateCallback => {
return (req, callback) => {
if (req.headers.origin?.startsWith(env.hiveServices.webApp.url)) {
// We need to treat requests from the web app a bit differently than others.
// The web app requires to define the `Access-Control-Allow-Origin` header (not *).
callback(null, {
origin: env.hiveServices.webApp.url,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Content-Type',
'graphql-client-version',
'graphql-client-name',
'x-request-id',
...supertokens.getAllCORSHeaders(),
],
});
return;
}
callback(null, {});
};
});
const storage = await createPostgreSQLStorage(createConnectionString(env.postgres), 10);
let dbPurgeTaskRunner: null | ReturnType<typeof createTaskRunner> = null;
@ -292,9 +327,7 @@ export async function main() {
schemaConfig: env.hiveServices.webApp
? {
schemaPublishLink(input) {
let url = `${env.hiveServices.webApp!.url}/${input.organization.cleanId}/${
input.project.cleanId
}/${input.target.cleanId}`;
let url = `${env.hiveServices.webApp.url}/${input.organization.cleanId}/${input.project.cleanId}/${input.target.cleanId}`;
if (input.version) {
url += `/history/${input.version.id}`;
@ -303,9 +336,7 @@ export async function main() {
return url;
},
schemaCheckLink(input) {
return `${env.hiveServices.webApp!.url}/${input.organization.cleanId}/${
input.project.cleanId
}/${input.target.cleanId}/checks/${input.schemaCheckId}`;
return `${env.hiveServices.webApp.url}/${input.organization.cleanId}/${input.project.cleanId}/${input.target.cleanId}/checks/${input.schemaCheckId}`;
},
}
: {},
@ -359,17 +390,26 @@ export async function main() {
const crypto = new CryptoProvider(env.encryptionSecret);
initSupertokens({
storage,
crypto,
logger: server.log,
});
await server.register(formDataPlugin);
await server.register(supertokensFastifyPlugin);
await registerTRPC(server, {
router: internalApiRouter,
createContext({ req }) {
return createContext({ storage, crypto, req });
createContext() {
return createContext({ storage, crypto });
},
});
server.route({
method: ['GET', 'HEAD'],
url: '/_health',
async handler(req, res) {
async handler(_, res) {
res.status(200).send(); // eslint-disable-line @typescript-eslint/no-floating-promises -- false positive, FastifyReply.then returns void
},
});

View file

@ -1,27 +1,50 @@
import { OverrideableBuilder } from 'supertokens-js-override/lib/build';
import EmailVerification from 'supertokens-node/recipe/emailverification';
import SessionNode from 'supertokens-node/recipe/session';
import type { FastifyBaseLogger, FastifyReply, FastifyRequest } from 'fastify';
import { CryptoProvider } from 'packages/services/api/src/modules/shared/providers/crypto';
import { OverrideableBuilder } from 'supertokens-js-override/lib/build/index.js';
import supertokens from 'supertokens-node';
import EmailVerification from 'supertokens-node/recipe/emailverification/index.js';
import SessionNode from 'supertokens-node/recipe/session/index.js';
import type { ProviderInput } from 'supertokens-node/recipe/thirdparty/types';
import ThirdPartyEmailPasswordNode from 'supertokens-node/recipe/thirdpartyemailpassword';
import { TypeInput as ThirdPartEmailPasswordTypeInput } from 'supertokens-node/recipe/thirdpartyemailpassword/types';
import { TypeInput } from 'supertokens-node/types';
import { env } from '@/env/backend';
import { appInfo } from '@/lib/supertokens/app-info';
import ThirdPartyEmailPasswordNode from 'supertokens-node/recipe/thirdpartyemailpassword/index.js';
import type { TypeInput as ThirdPartEmailPasswordTypeInput } from 'supertokens-node/recipe/thirdpartyemailpassword/types';
import type { TypeInput } from 'supertokens-node/types';
import zod from 'zod';
import { HiveError, type Storage } from '@hive/api';
import type { EmailsApi } from '@hive/emails';
import { captureException } from '@sentry/node';
import { createTRPCProxyClient, httpLink } from '@trpc/client';
import { createInternalApiCaller } from './api';
import { env } from './environment';
import {
createOIDCSuperTokensProvider,
getOIDCSuperTokensOverrides,
} from '@/lib/supertokens/third-party-email-password-node-oidc-provider';
import { createThirdPartyEmailPasswordNodeOktaProvider } from '@/lib/supertokens/third-party-email-password-node-okta-provider';
import type { EmailsApi } from '@hive/emails';
import type { InternalApi } from '@hive/server';
import { createTRPCProxyClient, CreateTRPCProxyClient, httpLink } from '@trpc/client';
} from './supertokens/oidc-provider';
import { createThirdPartyEmailPasswordNodeOktaProvider } from './supertokens/okta-provider';
export const backendConfig = (): TypeInput => {
const SuperTokenAccessTokenModel = zod.object({
version: zod.literal('1'),
superTokensUserId: zod.string(),
/**
* Supertokens for some reason omits externalUserId from the access token payload if it is null.
*/
externalUserId: zod.optional(zod.union([zod.string(), zod.null()])),
email: zod.string(),
});
export type SupertokensSession = zod.TypeOf<typeof SuperTokenAccessTokenModel>;
export const backendConfig = (requirements: {
storage: Storage;
crypto: CryptoProvider;
logger: FastifyBaseLogger;
}): TypeInput => {
const { logger } = requirements;
const emailsService = createTRPCProxyClient<EmailsApi>({
links: [httpLink({ url: `${env.emailsEndpoint}/trpc` })],
links: [httpLink({ url: `${env.hiveServices.emails?.endpoint}/trpc` })],
});
const internalApi = createTRPCProxyClient<InternalApi>({
links: [httpLink({ url: `${env.serverEndpoint}/trpc` })],
const internalApi = createInternalApiCaller({
storage: requirements.storage,
crypto: requirements.crypto,
});
const providers: ProviderInput[] = [];
@ -70,12 +93,24 @@ export const backendConfig = (): TypeInput => {
);
}
logger.info('SuperTokens providers: %s', providers.map(p => p.config.thirdPartyId).join(', '));
logger.info('SuperTokens websiteDomain: %s', env.hiveServices.webApp.url);
logger.info('SuperTokens apiDomain: %s', env.graphql.origin);
return {
framework: 'fastify',
supertokens: {
connectionURI: env.supertokens.connectionUri,
connectionURI: env.supertokens.connectionURI,
apiKey: env.supertokens.apiKey,
},
appInfo: appInfo(),
appInfo: {
// learn more about this on https://supertokens.com/docs/thirdpartyemailpassword/appinfo
appName: 'GraphQL Hive',
apiDomain: env.graphql.origin,
websiteDomain: env.hiveServices.webApp.url,
apiBasePath: '/auth-api',
websiteBasePath: '/auth',
},
recipeList: [
ThirdPartyEmailPasswordNode.init({
providers,
@ -166,7 +201,7 @@ export const backendConfig = (): TypeInput => {
};
const getEnsureUserOverrides = (
internalApi: CreateTRPCProxyClient<InternalApi>,
internalApi: ReturnType<typeof createInternalApiCaller>,
): ThirdPartEmailPasswordTypeInput['override'] => ({
apis: originalImplementation => ({
...originalImplementation,
@ -178,7 +213,7 @@ const getEnsureUserOverrides = (
const response = await originalImplementation.emailPasswordSignUpPOST(input);
if (response.status === 'OK') {
await internalApi.ensureUser.mutate({
await internalApi.ensureUser({
superTokensUserId: response.user.id,
email: response.user.email,
oidcIntegrationId: null,
@ -195,7 +230,7 @@ const getEnsureUserOverrides = (
const response = await originalImplementation.emailPasswordSignInPOST(input);
if (response.status === 'OK') {
await internalApi.ensureUser.mutate({
await internalApi.ensureUser({
superTokensUserId: response.user.id,
email: response.user.email,
oidcIntegrationId: null,
@ -211,7 +246,6 @@ const getEnsureUserOverrides = (
function extractOidcId(args: typeof input) {
if (input.provider.id === 'oidc') {
// eslint-disable-next-line prefer-destructuring
const oidcId: unknown = args.userContext['oidcId'];
if (typeof oidcId === 'string') {
return oidcId;
@ -223,7 +257,7 @@ const getEnsureUserOverrides = (
const response = await originalImplementation.thirdPartySignInUpPOST(input);
if (response.status === 'OK') {
await internalApi.ensureUser.mutate({
await internalApi.ensureUser({
superTokensUserId: response.user.id,
email: response.user.email,
oidcIntegrationId: extractOidcId(input),
@ -291,3 +325,64 @@ const composeSuperTokensOverrides = (
return impl;
},
});
export function initSupertokens(requirements: {
storage: Storage;
crypto: CryptoProvider;
logger: FastifyBaseLogger;
}) {
supertokens.init(backendConfig(requirements));
}
export async function resolveUser(ctx: { req: FastifyRequest; reply: FastifyReply }) {
let session: SessionNode.SessionContainer | undefined;
try {
session = await SessionNode.getSession(ctx.req, ctx.reply, {
sessionRequired: false,
antiCsrfCheck: false,
checkDatabase: true,
});
} catch (error) {
if (SessionNode.Error.isErrorFromSuperTokens(error)) {
// Check whether the email is already verified.
// If it is not then we need to redirect to the email verification page - which will trigger the email sending.
if (error.type === SessionNode.Error.INVALID_CLAIMS) {
throw new HiveError('Your account is not verified. Please verify your email address.', {
extensions: {
code: 'VERIFY_EMAIL',
},
});
} else if (
error.type === SessionNode.Error.TRY_REFRESH_TOKEN ||
error.type === SessionNode.Error.UNAUTHORISED
) {
throw new HiveError('Invalid session', {
extensions: {
code: 'NEEDS_REFRESH',
},
});
}
}
ctx.req.log.error(error, 'Error while resolving user');
captureException(error);
throw error;
}
const payload = session?.getAccessTokenPayload();
if (!payload) {
return null;
}
const result = SuperTokenAccessTokenModel.safeParse(payload);
if (result.success === false) {
console.error(`SuperTokens session verification failed.\n` + JSON.stringify(payload));
throw new HiveError(`Invalid token provided`);
}
return result.data;
}

View file

@ -1,13 +1,13 @@
import type { ExpressRequest } from 'supertokens-node/lib/build/framework/express/framework';
import type { FastifyRequest } from 'supertokens-node/lib/build/framework/fastify/framework';
import type { ProviderInput } from 'supertokens-node/recipe/thirdparty/types';
import type { TypeInput as ThirdPartEmailPasswordTypeInput } from 'supertokens-node/recipe/thirdpartyemailpassword/types';
import zod from 'zod';
import { getLogger } from '@/server-logger';
import type { InternalApi } from '@hive/server';
import type { CreateTRPCProxyClient } from '@trpc/client';
import { createInternalApiCaller } from '../api';
const couldNotResolveOidcIntegrationSymbol = Symbol('could_not_resolve_oidc_integration');
type InternalApiCaller = ReturnType<typeof createInternalApiCaller>;
export const getOIDCSuperTokensOverrides = (): ThirdPartEmailPasswordTypeInput['override'] => ({
apis(originalImplementation) {
return {
@ -27,18 +27,17 @@ export const getOIDCSuperTokensOverrides = (): ThirdPartEmailPasswordTypeInput['
});
export const createOIDCSuperTokensProvider = (args: {
internalApi: CreateTRPCProxyClient<InternalApi>;
internalApi: InternalApiCaller;
}): ProviderInput => ({
config: {
thirdPartyId: 'oidc',
},
override(originalImplementation) {
const logger = getLogger();
return {
...originalImplementation,
async getConfigForClientType(input) {
logger.info('resolve config for OIDC provider.');
console.info('resolve config for OIDC provider.');
const config = await getOIDCConfigFromInput(args.internalApi, input);
if (!config) {
// In the next step the override `authorisationUrlGET` from `getOIDCSuperTokensOverrides` is called.
@ -66,7 +65,7 @@ export const createOIDCSuperTokensProvider = (args: {
},
async getAuthorisationRedirectURL(input) {
logger.info('resolve config for OIDC provider.');
console.info('resolve config for OIDC provider.');
const oidcConfig = await getOIDCConfigFromInput(args.internalApi, input);
if (!oidcConfig) {
@ -88,7 +87,7 @@ export const createOIDCSuperTokensProvider = (args: {
},
async getUserInfo(input) {
logger.info('retrieve profile info from OIDC provider');
console.info('retrieve profile info from OIDC provider');
const config = await getOIDCConfigFromInput(args.internalApi, input);
if (!config) {
// This case should never be reached (guarded by getConfigForClientType).
@ -96,7 +95,7 @@ export const createOIDCSuperTokensProvider = (args: {
throw new Error('Could not find OIDC integration.');
}
logger.info('fetch info for OIDC provider (oidcId=%s)', config.id);
console.info('fetch info for OIDC provider (oidcId=%s)', config.id);
const tokenResponse = OIDCTokenSchema.parse(input.oAuthTokens);
const rawData: unknown = await fetch(config.userinfoEndpoint, {
@ -107,16 +106,16 @@ export const createOIDCSuperTokensProvider = (args: {
},
}).then(res => res.json());
logger.info('retrieved profile info for provider (oidcId=%s)', config.id);
console.info('retrieved profile info for provider (oidcId=%s)', config.id);
const dataParseResult = OIDCProfileInfoSchema.safeParse(rawData);
if (!dataParseResult.success) {
logger.error('Could not parse profile info for OIDC provider (oidcId=%s)', config.id);
logger.error('Raw data: %s', JSON.stringify(rawData));
logger.error('Error: %s', JSON.stringify(dataParseResult.error));
console.error('Could not parse profile info for OIDC provider (oidcId=%s)', config.id);
console.error('Raw data: %s', JSON.stringify(rawData));
console.error('Error: %s', JSON.stringify(dataParseResult.error));
for (const issue of dataParseResult.error.issues) {
logger.debug('Issue: %s', JSON.stringify(issue));
console.debug('Issue: %s', JSON.stringify(issue));
}
throw new Error('Could not parse profile info.');
}
@ -159,45 +158,40 @@ const OIDCProfileInfoSchema = zod.object({
const OIDCTokenSchema = zod.object({ access_token: zod.string() });
const getOIDCIdFromInput = (input: { userContext: any }): string => {
const expressRequest = input.userContext._default.request as ExpressRequest;
const originalUrl = 'http://localhost' + expressRequest.getOriginalURL();
const fastifyRequest = input.userContext._default.request as FastifyRequest;
const originalUrl = 'http://localhost' + fastifyRequest.getOriginalURL();
const oidcId = new URL(originalUrl).searchParams.get('oidc_id');
if (typeof oidcId !== 'string') {
const logger = getLogger();
logger.error('Invalid OIDC ID sent from client: %s', oidcId);
console.error('Invalid OIDC ID sent from client: %s', oidcId);
throw new Error('Invalid OIDC ID sent from client.');
}
return oidcId;
};
const configCache = new WeakMap<ExpressRequest, OIDCCOnfig | null>();
const configCache = new WeakMap<FastifyRequest, OIDCCOnfig | null>();
/**
* Get cached OIDC config from the supertokens input.
*/
async function getOIDCConfigFromInput(
internalApi: CreateTRPCProxyClient<InternalApi>,
input: { userContext: any },
) {
const expressRequest = input.userContext._default.request as ExpressRequest;
if (configCache.has(expressRequest)) {
return configCache.get(expressRequest) ?? null;
async function getOIDCConfigFromInput(internalApi: InternalApiCaller, input: { userContext: any }) {
const fastifyRequest = input.userContext._default.request as FastifyRequest;
if (configCache.has(fastifyRequest)) {
return configCache.get(fastifyRequest) ?? null;
}
const oidcId = getOIDCIdFromInput(input);
const config = await fetchOIDCConfig(internalApi, oidcId);
configCache.set(expressRequest, config);
configCache.set(fastifyRequest, config);
if (!config) {
const logger = getLogger();
logger.error('Could not find OIDC integration (oidcId: %s)', oidcId);
console.error('Could not find OIDC integration (oidcId: %s)', oidcId);
}
return config;
}
const fetchOIDCConfig = async (
internalApi: CreateTRPCProxyClient<InternalApi>,
internalApi: InternalApiCaller,
oidcIntegrationId: string,
): Promise<{
id: string;
@ -207,10 +201,10 @@ const fetchOIDCConfig = async (
userinfoEndpoint: string;
authorizationEndpoint: string;
} | null> => {
const result = await internalApi.getOIDCIntegrationById.query({ oidcIntegrationId });
const result = await internalApi.getOIDCIntegrationById({ oidcIntegrationId });
if (result === null) {
const logger = getLogger();
logger.error('OIDC integration not found. (oidcId=%s)', oidcIntegrationId);
// TODO: replace console.error with req.log.error
console.error('OIDC integration not found. (oidcId=%s)', oidcIntegrationId);
return null;
}
return result;

View file

@ -1,6 +1,6 @@
import type { ProviderInput } from 'supertokens-node/recipe/thirdparty/types';
import zod from 'zod';
import { env } from '@/env/backend';
import { env } from '../environment';
type OktaConfig = Exclude<(typeof env)['auth']['okta'], null>;
@ -58,7 +58,7 @@ const OktaProfileModel = zod.object({
}),
});
const fetchOktaProfile = async (config: OktaConfig, accessToken: string) => {
async function fetchOktaProfile(config: OktaConfig, accessToken: string) {
const response = await fetch(`${config.endpoint}/api/v1/users/me`, {
method: 'GET',
headers: {
@ -74,4 +74,4 @@ const fetchOktaProfile = async (config: OktaConfig, accessToken: string) => {
const json = await response.json();
return OktaProfileModel.parse(json);
};
}

View file

@ -13,6 +13,7 @@ export async function createServer(options: {
requests: boolean;
level: string;
};
cors?: boolean;
bodyLimit?: number;
}) {
const server = fastify({
@ -48,7 +49,9 @@ export async function createServer(options: {
await useRequestLogging(server);
}
await server.register(cors);
if (options.cors !== false) {
await server.register(cors);
}
return server;
}

View file

@ -1,31 +1,21 @@
GRAPHQL_ENDPOINT="http://localhost:3001/graphql"
APP_BASE_URL="http://localhost:3000"
SERVER_ENDPOINT="http://localhost:3001"
ENVIRONMENT="development"
# Supertokens
SUPERTOKENS_CONNECTION_URI="http://localhost:3567"
SUPERTOKENS_API_KEY="bubatzbieber6942096420"
# Public GraphQL endpoint
GRAPHQL_PUBLIC_ENDPOINT="http://localhost:3001/graphql"
GRAPHQL_PUBLIC_ORIGIN="http://localhost:3001"
# Auth Provider
## Enable GitHub login
AUTH_GITHUB="<sync>"
AUTH_GITHUB_CLIENT_ID="<sync>"
AUTH_GITHUB_CLIENT_SECRET="<sync>"
## Enable Google login
AUTH_GOOGLE="<sync>"
AUTH_GOOGLE_CLIENT_ID="<sync>"
AUTH_GOOGLE_CLIENT_SECRET="<sync>"
## Enable Okta login
AUTH_OKTA=0
AUTH_OKTA_HIDDEN=0
AUTH_OKTA_ENDPOINT="<sync>"
AUTH_OKTA_CLIENT_ID="<sync>"
AUTH_OKTA_CLIENT_SECRET="<sync>"
## Organization level Open ID Connect Authentication
AUTH_ORGANIZATION_OIDC=0
@ -41,7 +31,3 @@ INTEGRATION_GITHUB_APP_NAME="<sync>"
# Stripe
STRIPE_PUBLIC_KEY="<sync>"
# Emails Service
EMAILS_ENDPOINT=http://localhost:6260

View file

@ -30,6 +30,9 @@ npm-debug.log*
.env.test.local
.env.production.local
# next-env-runtime
public/__ENV.js
# vercel
.vercel

View file

@ -9,27 +9,17 @@ The following environment variables configure the application.
| Name | Required | Description | Example Value |
| --------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| `APP_BASE_URL` | **Yes** | The base url of the app, | `https://app.graphql-hive.com` |
| `SERVER_ENDPOINT` | **Yes** | The endpoint of the Hive server. | `http://127.0.0.1:4000` |
| `GRAPHQL_ENDPOINT` | **Yes** | The endpoint of the Hive GraphQL API. | `http://127.0.0.1:4000/graphql` |
| `EMAILS_ENDPOINT` | **Yes** | The endpoint of the GraphQL Hive Email service. | `http://127.0.0.1:6260` |
| `SUPERTOKENS_CONNECTION_URI` | **Yes** | The URI of the SuperTokens instance. | `http://127.0.0.1:3567` |
| `SUPERTOKENS_API_KEY` | **Yes** | The API KEY of the SuperTokens instance. | `iliketurtlesandicannotlie` |
| `GRAPHQL_PUBLIC_ENDPOINT` | **Yes** | The public endpoint of the Hive GraphQL API. | `http://127.0.0.1:4000/graphql` |
| `GRAPHQL_PUBLIC_ORIGIN` | **Yes** | The http address origin of the Hive GraphQL server. | `http://127.0.0.1:4000/` |
| `INTEGRATION_SLACK` | No | Whether the Slack integration is enabled or disabled. | `1` (enabled) or `0` (disabled) |
| `INTEGRATION_SLACK_SLACK_CLIENT_ID` | No (**Yes** if `INTEGRATION_SLACK` is set) | The Slack client ID. | `g6aff8102efda5e1d12e` |
| `INTEGRATION_SLACK_SLACK_CLIENT_SECRET` | No (**Yes** if `INTEGRATION_SLACK` is set) | The Slack client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` |
| `INTEGRATION_GITHUB_APP_NAME` | No | The GitHub application name. | `graphql-hive-self-hosted` |
| `AUTH_GITHUB` | No | Whether login via GitHub should be allowed | `1` (enabled) or `0` (disabled) |
| `AUTH_GITHUB_CLIENT_ID` | No (**Yes** if `AUTH_GITHUB` is set) | The GitHub client ID. | `g6aff8102efda5e1d12e` |
| `AUTH_GITHUB_CLIENT_SECRET` | No (**Yes** if `AUTH_GITHUB` is set) | The GitHub client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` |
| `AUTH_GOOGLE` | No | Whether login via Google should be allowed | `1` (enabled) or `0` (disabled) |
| `AUTH_GOOGLE_CLIENT_ID` | No (**Yes** if `AUTH_GOOGLE` is set) | The Google client ID. | `g6aff8102efda5e1d12e` |
| `AUTH_GOOGLE_CLIENT_SECRET` | No (**Yes** if `AUTH_GOOGLE` is set) | The Google client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` |
| `AUTH_ORGANIZATION_OIDC` | No | Whether linking a Hive organization to an Open ID Connect provider is allowed. (Default: `0`) | `1` (enabled) or `0` (disabled) |
| `AUTH_OKTA` | No | Whether login via Okta should be allowed | `1` (enabled) or `0` (disabled) |
| `AUTH_OKTA_CLIENT_ENDPOINT` | No (**Yes** if `AUTH_OKTA` is set) | The Okta endpoint. | `https://dev-1234567.okta.com` |
| `AUTH_OKTA_HIDDEN` | No | Whether the Okta login button should be hidden. (Default: `0`) | `1` (enabled) or `0` (disabled) |
| `AUTH_OKTA_CLIENT_ID` | No (**Yes** if `AUTH_OKTA` is set) | The Okta client ID. | `g6aff8102efda5e1d12e` |
| `AUTH_OKTA_CLIENT_SECRET` | No (**Yes** if `AUTH_OKTA` is set) | The Okta client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` |
| `AUTH_REQUIRE_EMAIL_VERIFICATION` | No | Whether verifying the email address is mandatory. | `1` (enabled) or `0` (disabled) |
| `ENVIRONMENT` | No | The environment of your Hive app. (**Note:** This will be used for Sentry reporting.) | `staging` |
| `SENTRY_DSN` | No | The DSN for reporting errors to Sentry. | `https://dooobars@o557896.ingest.sentry.io/12121212` |
@ -37,7 +27,7 @@ The following environment variables configure the application.
| `DOCS_URL` | No | The URL of the Hive Docs | `https://the-guild.dev/graphql/hive/docs` |
| `NODE_ENV` | No | The `NODE_ENV` value. | `production` |
| `GA_TRACKING_ID` | No | The token for Google Analytics in order to track user actions. | `g6aff8102efda5e1d12e` |
| `GRAPHQL_PERSISTED_OPERATIONS` | No | Send persisted oepration hashes instead of documents to the |
| `GRAPHQL_PERSISTED_OPERATIONS` | No | Send persisted operation hashes instead of documents to the |
| server. | `1` (enabled) or `0` (disabled) |
## Hive Hosted Configuration

View file

@ -1,250 +1,33 @@
import zod from 'zod';
import * as Sentry from '@sentry/nextjs';
import fs from 'node:fs';
import path from 'node:path';
import { ALLOWED_ENVIRONMENT_VARIABLES } from './src/env/frontend-public-variables';
console.log('🌲 Loading environment variables...');
//
// Runtime environment in Next.js
//
// Weird hacky way of getting the Sentry.Integrations object
// When the nextjs config is loaded by Next CLI Sentry has `Integrations` property.
// When nextjs starts and the `environment.js` is loaded, the Sentry object doesn't have the `Integrations` property, it' under `Sentry.default` property.
// Dealing with esm/cjs/default exports is a pain, we all feel that pain...
const Integrations =
'default' in Sentry
? ((Sentry as any).default as typeof Sentry).Integrations
: Sentry.Integrations;
configureRuntimeEnv(ALLOWED_ENVIRONMENT_VARIABLES);
// treat an empty string `''` as `undefined`
const emptyString = <T extends zod.ZodType>(input: T) => {
return zod.preprocess((value: unknown) => {
if (value === '') return undefined;
return value;
}, input);
};
const BaseSchema = zod.object({
NODE_ENV: zod.string(),
ENVIRONMENT: zod.string(),
APP_BASE_URL: zod.string().url(),
GRAPHQL_ENDPOINT: zod.string().url(),
SERVER_ENDPOINT: zod.string().url(),
EMAILS_ENDPOINT: zod.string().url(),
SUPERTOKENS_CONNECTION_URI: zod.string().url(),
SUPERTOKENS_API_KEY: zod.string(),
INTEGRATION_GITHUB_APP_NAME: emptyString(zod.string().optional()),
GA_TRACKING_ID: emptyString(zod.string().optional()),
DOCS_URL: emptyString(zod.string().url().optional()),
STRIPE_PUBLIC_KEY: emptyString(zod.string().optional()),
RELEASE: emptyString(zod.string().optional()),
AUTH_REQUIRE_EMAIL_VERIFICATION: emptyString(
zod.union([zod.literal('1'), zod.literal('0')]).optional(),
),
GRAPHQL_PERSISTED_OPERATIONS: emptyString(
zod.union([zod.literal('1'), zod.literal('0')]).optional(),
),
ZENDESK_SUPPORT: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
});
const IntegrationSlackSchema = zod.union([
zod.object({
INTEGRATION_SLACK: emptyString(zod.literal('0').optional()),
}),
zod.object({
INTEGRATION_SLACK: zod.literal('1'),
INTEGRATION_SLACK_CLIENT_ID: zod.string(),
INTEGRATION_SLACK_CLIENT_SECRET: zod.string(),
}),
]);
const AuthGitHubConfigSchema = zod.union([
zod.object({
AUTH_GITHUB: zod.union([zod.void(), zod.literal('0'), zod.literal('')]),
}),
zod.object({
AUTH_GITHUB: zod.literal('1'),
AUTH_GITHUB_CLIENT_ID: zod.string(),
AUTH_GITHUB_CLIENT_SECRET: zod.string(),
}),
]);
const AuthGoogleConfigSchema = zod.union([
zod.object({
AUTH_GOOGLE: zod.union([zod.void(), zod.literal('0'), zod.literal('')]),
}),
zod.object({
AUTH_GOOGLE: zod.literal('1'),
AUTH_GOOGLE_CLIENT_ID: zod.string(),
AUTH_GOOGLE_CLIENT_SECRET: zod.string(),
}),
]);
const AuthOktaConfigSchema = zod.union([
zod.object({
AUTH_OKTA: zod.union([zod.void(), zod.literal('0')]),
}),
zod.object({
AUTH_OKTA: zod.literal('1'),
AUTH_OKTA_HIDDEN: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
AUTH_OKTA_ENDPOINT: zod.string().url(),
AUTH_OKTA_CLIENT_ID: zod.string(),
AUTH_OKTA_CLIENT_SECRET: zod.string(),
}),
]);
const AuthOktaMultiTenantSchema = zod.object({
AUTH_ORGANIZATION_OIDC: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
});
const SentryConfigSchema = zod.union([
zod.object({
SENTRY: zod.union([zod.void(), zod.literal('0')]),
}),
zod.object({
SENTRY: zod.literal('1'),
SENTRY_DSN: zod.string(),
}),
]);
const MigrationsSchema = zod.object({
MEMBER_ROLES_DEADLINE: emptyString(
zod
.date({
coerce: true,
})
.optional(),
),
});
const configs = {
// Writes the environment variables to public/__ENV.js file and make them accessible under `window.__ENV`
function configureRuntimeEnv(publicEnvVars: readonly string[]) {
const envObject: Record<string, unknown> = {};
// eslint-disable-next-line no-process-env
base: BaseSchema.safeParse(process.env),
// eslint-disable-next-line no-process-env
integrationSlack: IntegrationSlackSchema.safeParse(process.env),
// eslint-disable-next-line no-process-env
sentry: SentryConfigSchema.safeParse(process.env),
// eslint-disable-next-line no-process-env
authGithub: AuthGitHubConfigSchema.safeParse(process.env),
// eslint-disable-next-line no-process-env
authGoogle: AuthGoogleConfigSchema.safeParse(process.env),
// eslint-disable-next-line no-process-env
authOkta: AuthOktaConfigSchema.safeParse(process.env),
// eslint-disable-next-line no-process-env
authOktaMultiTenant: AuthOktaMultiTenantSchema.safeParse(process.env),
// eslint-disable-next-line no-process-env
migrations: MigrationsSchema.safeParse(process.env),
};
const processEnv = process.env;
const environmentErrors: Array<string> = [];
for (const config of Object.values(configs)) {
if (config.success === false) {
environmentErrors.push(JSON.stringify(config.error.format(), null, 4));
for (const key in processEnv) {
if (publicEnvVars.includes(key)) {
envObject[key] = processEnv[key];
}
}
}
if (environmentErrors.length) {
const fullError = environmentErrors.join('\n');
console.error('❌ Invalid environment variables:', fullError);
process.exit(1);
}
const base = fs.realpathSync(process.cwd());
const file = `${base}/public/__ENV.js`;
const content = `window.__ENV = ${JSON.stringify(envObject)};`;
const dirname = path.dirname(file);
function extractConfig<Input, Output>(config: zod.SafeParseReturnType<Input, Output>): Output {
if (!config.success) {
throw new Error('Something went wrong.');
if (!fs.existsSync(dirname)) {
fs.mkdirSync(dirname, { recursive: true });
}
return config.data;
fs.writeFileSync(file, content);
}
const base = extractConfig(configs.base);
const integrationSlack = extractConfig(configs.integrationSlack);
const sentry = extractConfig(configs.sentry);
const authGithub = extractConfig(configs.authGithub);
const authGoogle = extractConfig(configs.authGoogle);
const authOkta = extractConfig(configs.authOkta);
const authOktaMultiTenant = extractConfig(configs.authOktaMultiTenant);
const migrations = extractConfig(configs.migrations);
const config = {
release: base.RELEASE ?? 'local',
nodeEnv: base.NODE_ENV,
environment: base.ENVIRONMENT,
appBaseUrl: base.APP_BASE_URL.replace(/\/$/, ''),
graphqlEndpoint: base.GRAPHQL_ENDPOINT,
serverEndpoint: base.SERVER_ENDPOINT,
emailsEndpoint: base.EMAILS_ENDPOINT,
supertokens: {
connectionUri: base.SUPERTOKENS_CONNECTION_URI,
apiKey: base.SUPERTOKENS_API_KEY,
},
slack:
integrationSlack.INTEGRATION_SLACK === '1'
? {
clientId: integrationSlack.INTEGRATION_SLACK_CLIENT_ID,
clientSecret: integrationSlack.INTEGRATION_SLACK_CLIENT_SECRET,
}
: null,
github: base.INTEGRATION_GITHUB_APP_NAME ? { appName: base.INTEGRATION_GITHUB_APP_NAME } : null,
analytics: {
googleAnalyticsTrackingId: base.GA_TRACKING_ID,
},
docsUrl: base.DOCS_URL,
auth: {
github:
authGithub.AUTH_GITHUB === '1'
? {
clientId: authGithub.AUTH_GITHUB_CLIENT_ID,
clientSecret: authGithub.AUTH_GITHUB_CLIENT_SECRET,
}
: null,
google:
authGoogle.AUTH_GOOGLE === '1'
? {
clientId: authGoogle.AUTH_GOOGLE_CLIENT_ID,
clientSecret: authGoogle.AUTH_GOOGLE_CLIENT_SECRET,
}
: null,
okta:
authOkta.AUTH_OKTA === '1'
? {
endpoint: authOkta.AUTH_OKTA_ENDPOINT,
hidden: authOkta.AUTH_OKTA_HIDDEN === '1',
clientId: authOkta.AUTH_OKTA_CLIENT_ID,
clientSecret: authOkta.AUTH_OKTA_CLIENT_SECRET,
}
: null,
organizationOIDC: authOktaMultiTenant.AUTH_ORGANIZATION_OIDC === '1',
requireEmailVerification: base.AUTH_REQUIRE_EMAIL_VERIFICATION === '1',
},
sentry: sentry.SENTRY === '1' ? { dsn: sentry.SENTRY_DSN } : null,
stripePublicKey: base.STRIPE_PUBLIC_KEY ?? null,
graphql: {
persistedOperations: base.GRAPHQL_PERSISTED_OPERATIONS === '1',
},
zendeskSupport: base.ZENDESK_SUPPORT === '1',
migrations: {
member_roles_deadline: migrations.MEMBER_ROLES_DEADLINE ?? null,
},
} as const;
declare global {
// eslint-disable-next-line no-var
var __backend_env: typeof config;
}
globalThis['__backend_env'] = config;
// TODO: I don't like this here, but it seems like it makes most sense here :)
Sentry.init({
serverName: 'app',
dist: 'app',
enabled: !!config.sentry,
enableTracing: false,
tracesSampleRate: 1,
dsn: config.sentry?.dsn,
release: config.release,
environment: config.environment,
integrations: [
// HTTP integration is only available on the server
new Integrations.Http({
tracing: false,
}),
],
});

View file

@ -1,10 +1,5 @@
import bundleAnalyzer from '@next/bundle-analyzer';
// For the dev server we want to make sure that the correct environment variables are set :)
// during build we don't need environment variables!
if (globalThis.process.env.BUILD !== '1') {
await import('./environment');
}
import './environment';
const withBundleAnalyzer = bundleAnalyzer({
enabled: globalThis.process.env.ANALYZE === '1',

View file

@ -4,12 +4,12 @@
"private": true,
"scripts": {
"analyze": "pnpm build:config && ANALYZE=1 next build",
"build": "pnpm generate-changelog && pnpm build:config && BUILD=1 next build && tsx ../../../scripts/runify.ts",
"build": "pnpm generate-changelog && pnpm build:config && next build && tsx ../../../scripts/runify.ts",
"build-storybook": "storybook build",
"build:config": "tsup-node --no-splitting --out-dir . --loader \".mts=ts\" --format esm --target node21 next.config.mts",
"dev": "pnpm generate-changelog && pnpm build:config && next dev | pino-pretty",
"generate-changelog": "node ../../../scripts/generate-changelog.js",
"postbuild": "pnpm --filter @hive/app deploy --prod --no-optional deploy-tmp/web && rimraf dist/node_modules && mv deploy-tmp/web/node_modules dist && rimraf deploy-tmp/web",
"postbuild": "rimraf deploy-tmp/web && pnpm --filter @hive/app deploy --prod --no-optional deploy-tmp/web && rimraf dist/node_modules && mv deploy-tmp/web/node_modules dist && rimraf deploy-tmp/web",
"postinstall": "pnpm generate-changelog",
"start": "node dist/index.js",
"storybook": "storybook dev -p 6006",
@ -53,6 +53,7 @@
"@trpc/client": "10.45.2",
"@trpc/server": "10.45.2",
"@urql/core": "4.1.4",
"@urql/exchange-auth": "2.1.6",
"@urql/exchange-graphcache": "6.3.3",
"@urql/exchange-persisted": "4.1.0",
"class-variance-authority": "0.7.0",
@ -93,8 +94,6 @@
"regenerator-runtime": "0.14.1",
"snarkdown": "2.0.0",
"supertokens-auth-react": "0.35.6",
"supertokens-js-override": "0.0.4",
"supertokens-node": "15.2.1",
"supertokens-web-js": "0.8.0",
"tailwind-merge": "2.2.2",
"tslib": "2.6.2",

View file

@ -37,7 +37,6 @@ import { FragmentType, graphql, useFragment } from '@/gql';
import { CriticalityLevel } from '@/gql/graphql';
import { ProjectType } from '@/graphql';
import { useRouteSelector } from '@/lib/hooks';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { cn } from '@/lib/utils';
import {
CheckIcon,
@ -166,7 +165,7 @@ const Navigation = (props: {
</NextLink>
{edge.node.githubRepository && edge.node.meta ? (
<a
className="ml-[-1px] text-xs font-medium text-gray-500 hover:text-gray-400"
className="-ml-px text-xs font-medium text-gray-500 hover:text-gray-400"
target="_blank"
rel="noreferrer"
href={`https://github.com/${edge.node.githubRepository}/commit/${edge.node.meta.commit}`}
@ -1492,6 +1491,4 @@ function ChecksPage() {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(ChecksPage);

View file

@ -1,5 +1 @@
import { withSessionProtection } from '@/lib/supertokens/guard';
export { default } from '../checks';
export const getServerSideProps = withSessionProtection();

View file

@ -18,7 +18,6 @@ import { MetaTitle } from '@/components/v2';
import { noSchemaVersion, noValidSchemaVersion } from '@/components/v2/empty-list';
import { FragmentType, graphql, useFragment } from '@/gql';
import { useRouteSelector } from '@/lib/hooks';
import { withSessionProtection } from '@/lib/supertokens/guard';
const ExplorerPage_SchemaExplorerFragment = graphql(`
fragment ExplorerPage_SchemaExplorerFragment on SchemaExplorer {
@ -161,6 +160,9 @@ function ExplorerPageContent() {
}
}, [setDataRetentionInDays, retentionInDays]);
/* to avoid janky behaviour we keep track if the version has a successful explorer once, and in that case always show the filter bar. */
const isFilterVisible = useRef(false);
if (query.error) {
return <QueryError error={query.error} />;
}
@ -173,9 +175,6 @@ function ExplorerPageContent() {
const latestSchemaVersion = currentTarget?.latestSchemaVersion;
const latestValidSchemaVersion = currentTarget?.latestValidSchemaVersion;
/* to avoid janky behaviour we keep track if the version has a successful explorer once, and in that case always show the filter bar. */
const isFilterVisible = useRef(false);
if (latestValidSchemaVersion?.explorer) {
isFilterVisible.current = true;
}
@ -280,6 +279,4 @@ function ExplorerPage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(ExplorerPage);

View file

@ -19,7 +19,6 @@ import { MetaTitle } from '@/components/v2';
import { noSchemaVersion } from '@/components/v2/empty-list';
import { FragmentType, graphql, useFragment } from '@/gql';
import { useRouteSelector } from '@/lib/hooks/use-route-selector';
import { withSessionProtection } from '@/lib/supertokens/guard';
export const TypeRenderFragment = graphql(`
fragment TypeRenderFragment on GraphQLNamedType {
@ -256,6 +255,4 @@ function TypeExplorerPage() {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(TypeExplorerPage);

View file

@ -14,7 +14,6 @@ import { EmptyList, noSchemaVersion } from '@/components/v2/empty-list';
import { FragmentType, graphql, useFragment } from '@/gql';
import { useRouteSelector } from '@/lib/hooks';
import { useDateRangeController } from '@/lib/hooks/use-date-range-controller';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { cn } from '@/lib/utils';
import { TypeRenderer, TypeRenderFragment } from './[typename]';
@ -382,6 +381,4 @@ function UnusedSchemaExplorerPage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(UnusedSchemaExplorerPage);

View file

@ -19,7 +19,6 @@ import { DiffIcon } from '@/components/v2/icon';
import { FragmentType, graphql, useFragment } from '@/gql';
import { CriticalityLevel, ProjectType } from '@/graphql';
import { useRouteSelector } from '@/lib/hooks/use-route-selector';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { cn } from '@/lib/utils';
import {
CheckCircledIcon,
@ -164,7 +163,7 @@ function ListPage({
</NextLink>
{version.githubMetadata ? (
<a
className="ml-[-1px] text-xs font-medium text-gray-500 hover:text-gray-400"
className="-ml-px text-xs font-medium text-gray-500 hover:text-gray-400"
target="_blank"
rel="noreferrer"
href={`https://github.com/${version.githubMetadata.repository}/commit/${version.githubMetadata.commit}`}
@ -884,6 +883,4 @@ function HistoryPage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(HistoryPage);

View file

@ -1,5 +1 @@
import { withSessionProtection } from '@/lib/supertokens/guard';
export { default } from '../history';
export const getServerSideProps = withSessionProtection();

View file

@ -15,7 +15,6 @@ import { DocumentType, FragmentType, graphql, useFragment } from '@/gql';
import { ProjectType, RegistryModel } from '@/graphql';
import { TargetAccessScope, useTargetAccess } from '@/lib/access/target';
import { useRouteSelector } from '@/lib/hooks';
import { withSessionProtection } from '@/lib/supertokens/guard';
type CompositeSchema = Extract<
DocumentType<typeof SchemaView_SchemaFragment>,
@ -361,6 +360,4 @@ function SchemaPage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(SchemaPage);

View file

@ -17,7 +17,6 @@ import { EmptyList, MetaTitle } from '@/components/v2';
import { graphql } from '@/gql';
import { useRouteSelector } from '@/lib/hooks';
import { useDateRangeController } from '@/lib/hooks/use-date-range-controller';
import { withSessionProtection } from '@/lib/supertokens/guard';
function OperationsView({
organizationCleanId,
@ -187,6 +186,4 @@ function InsightsPage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(InsightsPage);

View file

@ -17,7 +17,6 @@ import { EmptyList, Link, MetaTitle } from '@/components/v2';
import { FragmentType, graphql, useFragment } from '@/gql';
import { useRouteSelector } from '@/lib/hooks';
import { useDateRangeController } from '@/lib/hooks/use-date-range-controller';
import { withSessionProtection } from '@/lib/supertokens/guard';
const GraphQLOperationBody_OperationFragment = graphql(`
fragment GraphQLOperationBody_OperationFragment on Operation {
@ -265,6 +264,4 @@ function OperationInsightsPage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(OperationInsightsPage);

View file

@ -18,7 +18,6 @@ import { CHART_PRIMARY_COLOR } from '@/constants';
import { graphql } from '@/gql';
import { formatNumber, formatThroughput, toDecimal, useRouteSelector } from '@/lib/hooks';
import { useDateRangeController } from '@/lib/hooks/use-date-range-controller';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { useChartStyles } from '@/utils';
const ClientView_ClientStatsQuery = graphql(`
@ -442,6 +441,4 @@ function ClientInsightsPage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(ClientInsightsPage);

View file

@ -26,7 +26,6 @@ import { CHART_PRIMARY_COLOR } from '@/constants';
import { graphql } from '@/gql';
import { formatNumber, formatThroughput, toDecimal, useRouteSelector } from '@/lib/hooks';
import { useDateRangeController } from '@/lib/hooks/use-date-range-controller';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { useChartStyles } from '@/utils';
const SchemaCoordinateView_SchemaCoordinateStatsQuery = graphql(`
@ -496,6 +495,4 @@ function SchemaCoordinatePage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(SchemaCoordinatePage);

View file

@ -28,7 +28,6 @@ import { graphql } from '@/gql';
import { TargetAccessScope } from '@/gql/graphql';
import { canAccessTarget } from '@/lib/access/target';
import { useClipboard, useNotifications, useRouteSelector, useToggle } from '@/lib/hooks';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { cn } from '@/lib/utils';
import {
Button as GraphiQLButton,
@ -824,6 +823,7 @@ function LaboratoryPageContent() {
(actualSelectedApiEndpoint === 'linkedApi'
? query.data?.target?.graphqlEndpointUrl
: undefined) ?? mockEndpoint,
fetch,
});
const result = await fetcher(params, opts);
@ -1052,8 +1052,6 @@ function LaboratoryPage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(LaboratoryPage);
function useApiTabValueState(graphqlEndpointUrl: string | null) {

View file

@ -42,7 +42,6 @@ import { ProjectType } from '@/gql/graphql';
import { canAccessTarget, TargetAccessScope } from '@/lib/access/target';
import { subDays } from '@/lib/date-time';
import { useRouteSelector, useToggle } from '@/lib/hooks';
import { withSessionProtection } from '@/lib/supertokens/guard';
const SetTargetValidationMutation = graphql(`
mutation Settings_SetTargetValidation($input: SetTargetValidationInput!) {
@ -1060,6 +1059,4 @@ function SettingsPage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(SettingsPage);

View file

@ -21,7 +21,6 @@ import { FragmentType, graphql, useFragment } from '@/gql';
import { subDays } from '@/lib/date-time';
import { useFormattedNumber } from '@/lib/hooks';
import { useRouteSelector } from '@/lib/hooks/use-route-selector';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { cn, pluralize } from '@/lib/utils';
function floorDate(date: Date): Date {
@ -383,6 +382,4 @@ function ProjectsPage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(ProjectsPage);

View file

@ -30,7 +30,6 @@ import { DocsLink, MetaTitle } from '@/components/v2';
import { FragmentType, graphql, useFragment } from '@/gql';
import { ProjectAccessScope, useProjectAccess } from '@/lib/access/project';
import { useRouteSelector, useToggle } from '@/lib/hooks';
import { withSessionProtection } from '@/lib/supertokens/guard';
function Channels(props: {
channels: FragmentType<typeof ChannelsTable_AlertChannelFragment>[];
@ -265,6 +264,4 @@ function AlertsPage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(AlertsPage);

View file

@ -12,7 +12,6 @@ import { ProjectAccessScope } from '@/gql/graphql';
import { RegistryModel } from '@/graphql';
import { useProjectAccess } from '@/lib/access/project';
import { useRouteSelector } from '@/lib/hooks';
import { withSessionProtection } from '@/lib/supertokens/guard';
const ProjectPolicyPageQuery = graphql(`
query ProjectPolicyPageQuery($organizationId: ID!, $projectId: ID!) {
@ -206,6 +205,4 @@ function ProjectPolicyPage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(ProjectPolicyPage);

View file

@ -28,7 +28,6 @@ import { ProjectType } from '@/graphql';
import { canAccessProject, ProjectAccessScope, useProjectAccess } from '@/lib/access/project';
import { getDocsUrl } from '@/lib/docs-url';
import { useNotifications, useRouteSelector, useToggle } from '@/lib/hooks';
import { withSessionProtection } from '@/lib/supertokens/guard';
const GithubIntegration_GithubIntegrationDetailsQuery = graphql(`
query getGitHubIntegrationDetails($selector: OrganizationSelectorInput!) {
@ -107,7 +106,7 @@ function GitHubIntegration(props: {
<div className="flex items-center gap-x-2 pl-1">
<CheckIcon className="size-4 text-emerald-500" />
<div className="flex size-6 items-center justify-center rounded-sm bg-white">
<HiveLogo className="size-[80%]" />
<HiveLogo className="size-4/5" />
</div>
<div className="font-semibold text-[#adbac7]">
@ -123,7 +122,7 @@ function GitHubIntegration(props: {
<div className="flex items-center gap-x-2 pl-1">
<CheckIcon className="size-4 text-emerald-500" />
<div className="flex size-6 items-center justify-center rounded-sm bg-white">
<HiveLogo className="size-[80%]" />
<HiveLogo className="size-4/5" />
</div>
<div className="font-semibold text-[#adbac7]">
@ -414,6 +413,4 @@ function SettingsPage() {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(SettingsPage);

View file

@ -19,11 +19,9 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { Activities, Card, EmptyList, MetaTitle } from '@/components/v2';
import { FragmentType, graphql, useFragment } from '@/gql';
import { ProjectType } from '@/gql/graphql';
import { writeLastVisitedOrganization } from '@/lib/cookies';
import { subDays } from '@/lib/date-time';
import { useFormattedNumber } from '@/lib/hooks';
import { useRouteSelector } from '@/lib/hooks/use-route-selector';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { pluralize } from '@/lib/utils';
function floorDate(date: Date): Date {
@ -403,9 +401,4 @@ function OrganizationPage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection(async ({ req, res, resolvedUrl }) => {
writeLastVisitedOrganization(req, res, resolvedUrl.substring(1));
return { props: {} };
});
export default authenticated(OrganizationPage);

View file

@ -16,7 +16,6 @@ import { BillingPlanType } from '@/gql/graphql';
import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization';
import { getIsStripeEnabled } from '@/lib/billing/stripe-public-key';
import { useRouteSelector } from '@/lib/hooks';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js';
const ManageSubscriptionInner_OrganizationFragment = graphql(`
@ -168,6 +167,16 @@ function Inner(props: {
[setOperationsRateLimit],
);
const isFetching =
updateOrgRateLimitMutationState.fetching ||
downgradeToHobbyMutationState.fetching ||
upgradeToProMutationState.fetching;
const billingPlans = useFragment(
ManageSubscriptionInner_BillingPlansFragment,
props.billingPlans,
);
useEffect(() => {
if (query.data?.billingPlans?.length) {
if (organization.plan === plan) {
@ -182,15 +191,6 @@ function Inner(props: {
}
}, [organization.plan, organization.rateLimit.operations, plan, query.data?.billingPlans]);
if (!canAccess) {
return null;
}
const isFetching =
updateOrgRateLimitMutationState.fetching ||
downgradeToHobbyMutationState.fetching ||
upgradeToProMutationState.fetching;
const upgrade = useCallback(async () => {
if (isFetching) {
return;
@ -262,6 +262,10 @@ function Inner(props: {
});
}, [organization.cleanId, operationsRateLimit, updateOrgRateLimitMutation, isFetching]);
if (!canAccess) {
return null;
}
const renderActions = () => {
if (plan === organization.plan) {
if (organization.rateLimit.operations !== operationsRateLimit * 1_000_000) {
@ -312,11 +316,6 @@ function Inner(props: {
downgradeToHobbyMutationState.error ||
updateOrgRateLimitMutationState.error;
const billingPlans = useFragment(
ManageSubscriptionInner_BillingPlansFragment,
props.billingPlans,
);
// TODO: this is also not safe as billingPlans might be an empty list.
const selectedPlan = billingPlans.find(v => v.planType === plan) ?? billingPlans[0];
@ -441,6 +440,20 @@ const ManageSubscriptionPageQuery = graphql(`
function ManageSubscriptionPageContent() {
const router = useRouteSelector();
/**
* If Stripe is not enabled we redirect the user to the organization.
*/
if (!getIsStripeEnabled()) {
void router.push({
pathname: '/[organizationId]',
query: {
organizationId: router.organizationId,
},
});
return null;
}
const [query] = useQuery({
query: ManageSubscriptionPageQuery,
variables: {
@ -523,22 +536,4 @@ function ManageSubscriptionPage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection(async context => {
/**
* If Stripe is not enabled we redirect the user to the organization.
*/
const isStripeEnabled = getIsStripeEnabled();
if (!isStripeEnabled) {
const parts = String(context.resolvedUrl).split('/');
parts.pop();
return {
redirect: {
destination: parts.join('/'),
permanent: false,
},
};
}
return { props: {} };
});
export default authenticated(ManageSubscriptionPage);

View file

@ -13,7 +13,6 @@ import { MetaTitle } from '@/components/v2';
import { FragmentType, graphql, useFragment } from '@/gql';
import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization';
import { useRouteSelector } from '@/lib/hooks/use-route-selector';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { cn } from '@/lib/utils';
const OrganizationMembersPage_OrganizationFragment = graphql(`
@ -225,6 +224,4 @@ function OrganizationMembersPage() {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(OrganizationMembersPage);

View file

@ -13,7 +13,6 @@ import { OrganizationAccessScope } from '@/gql/graphql';
import { RegistryModel } from '@/graphql';
import { useOrganizationAccess } from '@/lib/access/organization';
import { useRouteSelector } from '@/lib/hooks';
import { withSessionProtection } from '@/lib/supertokens/guard';
const OrganizationPolicyPageQuery = graphql(`
query OrganizationPolicyPageQuery($selector: OrganizationSelectorInput!) {
@ -223,6 +222,4 @@ function OrganizationPolicyPage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(OrganizationPolicyPage);

View file

@ -31,7 +31,6 @@ import {
useOrganizationAccess,
} from '@/lib/access/organization';
import { useRouteSelector, useToggle } from '@/lib/hooks';
import { withSessionProtection } from '@/lib/supertokens/guard';
const Integrations_CheckIntegrationsQuery = graphql(`
query Integrations_CheckIntegrationsQuery($selector: OrganizationSelectorInput!) {
@ -479,6 +478,4 @@ function OrganizationSettingsPage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(OrganizationSettingsPage);

View file

@ -16,7 +16,6 @@ import { graphql, useFragment } from '@/gql';
import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization';
import { getIsStripeEnabled } from '@/lib/billing/stripe-public-key';
import { useRouteSelector } from '@/lib/hooks';
import { withSessionProtection } from '@/lib/supertokens/guard';
const DateFormatter = Intl.DateTimeFormat('en-US', {
month: 'short',
@ -75,6 +74,17 @@ const SubscriptionPageQuery = graphql(`
function SubscriptionPageContent() {
const router = useRouteSelector();
if (!getIsStripeEnabled()) {
void router.push({
pathname: '/[organizationId]',
query: {
organizationId: router.organizationId,
},
});
return null;
}
const [query] = useQuery({
query: SubscriptionPageQuery,
variables: {
@ -200,22 +210,4 @@ function SubscriptionPage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection(async context => {
/**
* If Stripe is not enabled we redirect the user to the organization.
*/
const isStripeEnabled = getIsStripeEnabled();
if (!isStripeEnabled) {
const parts = String(context.resolvedUrl).split('/');
parts.pop();
return {
redirect: {
destination: parts.join('/'),
permanent: false,
},
};
}
return { props: {} };
});
export default authenticated(SubscriptionPage);

View file

@ -45,7 +45,6 @@ import { FragmentType, graphql, useFragment } from '@/gql';
import { SupportTicketPriority, SupportTicketStatus } from '@/gql/graphql';
import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization';
import { useNotifications, useRouteSelector, useToggle } from '@/lib/hooks';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { cn } from '@/lib/utils';
import { zodResolver } from '@hookform/resolvers/zod';
@ -457,6 +456,4 @@ function SupportPage() {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(SupportPage);

View file

@ -19,7 +19,6 @@ import { MetaTitle } from '@/components/v2';
import { FragmentType, graphql, useFragment } from '@/gql';
import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization';
import { useNotifications, useRouteSelector } from '@/lib/hooks';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { cn } from '@/lib/utils';
import { zodResolver } from '@hookform/resolvers/zod';
@ -366,6 +365,4 @@ function SupportTicketPage() {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(SupportTicketPage);

View file

@ -1,25 +1,17 @@
import Document, { DocumentContext, Head, Html, Main, NextScript } from 'next/document';
import 'regenerator-runtime/runtime';
// don't remove this import ; it will break the built app ; but not the dev app :)
import '@/config/frontend-env';
export default class MyDocument extends Document<{
ids: Array<string>;
css: string;
frontendEnv: (typeof import('@/config/frontend-env'))['env'];
}> {
static async getInitialProps(ctx: DocumentContext) {
if (globalThis.process.env.BUILD !== '1') {
await import('../environment');
}
const { env: frontendEnv } = await import('@/config/frontend-env');
const initialProps = await Document.getInitialProps(ctx);
const page = await ctx.renderPage();
return {
...initialProps,
...page,
frontendEnv,
};
}
@ -37,14 +29,8 @@ export default class MyDocument extends Document<{
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<link rel="icon" href="/just-logo.svg" type="image/svg+xml" />
<script
type="module"
dangerouslySetInnerHTML={{
__html: `globalThis.__frontend_env = ${JSON.stringify(
(this.props as any).frontendEnv,
)}`,
}}
/>
{/* eslint-disable-next-line @next/next/no-sync-scripts -- if it's not sync, then env variables are not present) */}
<script src="/__ENV.js" />
</Head>
<body className="bg-transparent font-sans text-white">
<Main />

View file

@ -7,7 +7,6 @@ import { Button, DataWrapper } from '@/components/v2';
import { graphql } from '@/gql';
import { useNotifications } from '@/lib/hooks/use-notifications';
import { useRouteSelector } from '@/lib/hooks/use-route-selector';
import { withSessionProtection } from '@/lib/supertokens/guard';
const classes = {
title: clsx('sm:text-4xl text-3xl mb-4 font-medium text-white'),
@ -148,6 +147,4 @@ function OrganizationTransferPage() {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(OrganizationTransferPage);

View file

@ -1,38 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next';
import type { Request, Response } from 'express';
import NextCors from 'nextjs-cors';
import supertokens from 'supertokens-node';
import { middleware } from 'supertokens-node/framework/express';
import { superTokensNextWrapper } from 'supertokens-node/nextjs';
import { backendConfig } from '@/config/supertokens/backend';
import { env } from '@/env/backend';
supertokens.init(backendConfig());
/**
* Route for proxying to the underlying SuperTokens backend.
*/
export default async function superTokens(
req: NextApiRequest & Request,
res: NextApiResponse & Response,
) {
// NOTE: We need CORS only if we are querying the APIs from a different origin
await NextCors(req, res, {
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
origin: env.appBaseUrl,
credentials: true,
allowedHeaders: ['content-type', ...supertokens.getAllCORSHeaders()],
});
await superTokensNextWrapper(
async next => {
await middleware()(req, res, next);
},
req,
res,
);
if (!res.writableEnded) {
res.status(404).send('Not found');
}
}

View file

@ -11,7 +11,7 @@ export async function ensureGithubIntegration(
) {
const { orgId, installationId } = input;
await graphql({
url: `${env.appBaseUrl}/api/proxy`,
url: env.graphqlPublicEndpoint,
headers: {
...req.headers,
'content-type': 'application/json',

View file

@ -22,7 +22,7 @@ export default async function githubSetupCallback(req: NextApiRequest, res: Next
cleanId: string;
};
}>({
url: `${env.appBaseUrl}/api/proxy`,
url: env.graphqlPublicEndpoint,
headers: {
...req.headers,
'content-type': 'application/json',

View file

@ -1,5 +1,5 @@
import { NextApiRequest, NextApiResponse } from 'next';
export default async function graphql(req: NextApiRequest, res: NextApiResponse) {
export default async function health(req: NextApiRequest, res: NextApiResponse) {
res.status(200).json({});
}

View file

@ -1,48 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { getLogger } from '@/server-logger';
import { captureException } from '@sentry/nextjs';
async function joinWaitingList(req: NextApiRequest, res: NextApiResponse) {
const logger = getLogger(req);
function success(message: string) {
res.status(200).json({
ok: true,
message,
});
}
function failure(message: string) {
res.status(200).json({
ok: false,
message,
});
}
try {
logger.info('Joining the waiting list (input=%o)', req.body);
if (req.body.email) {
await fetch(`https://guild-ms-slack-bot.vercel.app/api/hive?email=${req.body.email}`, {
method: 'GET',
});
} else {
return failure('Missing email');
}
return success('Thank you for joining our waiting list!');
} catch (error) {
captureException(error);
logger.error(`Failed to join waiting list: ${error}`);
logger.error(error);
return failure('Failed to join. Try again or contact@the-guild.dev');
}
}
export default joinWaitingList;
export const config = {
api: {
externalResolver: true,
},
};

View file

@ -1,13 +1,13 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { buildSchema, execute, GraphQLError, parse } from 'graphql';
import { env } from '@/env/backend';
import { extractAccessTokenFromRequest } from '@/lib/api/extract-access-token-from-request';
import { getLogger } from '@/server-logger';
import { addMocksToSchema } from '@graphql-tools/mock';
// TODO: check if lab is working
async function lab(req: NextApiRequest, res: NextApiResponse) {
const logger = getLogger(req);
const url = env.graphqlEndpoint;
const url = env.graphqlPublicEndpoint;
const labParams = req.query.lab || [];
if (labParams.length < 3) {
@ -22,24 +22,9 @@ async function lab(req: NextApiRequest, res: NextApiResponse) {
const headers: Record<string, string> = {};
if (req.headers['x-hive-key']) {
// TODO: change that to Authorization: Bearer
headers['X-API-Token'] = req.headers['x-hive-key'] as string;
headers['Authorization'] = `Bearer ${req.headers['x-hive-key'] as string}`;
} else {
try {
const accessToken = await extractAccessTokenFromRequest(req, res);
if (!accessToken) {
throw 'Invalid Token!';
}
headers['Authorization'] = `Bearer ${accessToken}`;
} catch (error) {
console.warn('Lab auth failed:', error);
res.status(200).send({
errors: [new GraphQLError('Invalid or missing X-Hive-Key authentication')],
});
return;
}
headers['Cookie'] = req.headers.cookie as string;
}
const body = {
@ -72,6 +57,7 @@ async function lab(req: NextApiRequest, res: NextApiResponse) {
'graphql-client-version': env.release,
...headers,
},
credentials: 'include',
method: 'POST',
body: JSON.stringify(body),
});

View file

@ -1,106 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next';
import hyperid from 'hyperid';
import { env } from '@/env/backend';
import { extractAccessTokenFromRequest } from '@/lib/api/extract-access-token-from-request';
import { getLogger } from '@/server-logger';
import { captureException } from '@sentry/nextjs';
const reqIdGenerate = hyperid({ fixedLength: true });
async function graphql(req: NextApiRequest, res: NextApiResponse) {
const logger = getLogger(req);
const url = env.graphqlEndpoint;
const requestIdHeader = req.headers['x-request-id'];
const requestId = Array.isArray(requestIdHeader)
? requestIdHeader[0]
: requestIdHeader ?? reqIdGenerate();
if (req.method === 'GET') {
const response = await fetch(url, {
headers: {
'content-type': req.headers['content-type'],
accept: req.headers['accept'],
'accept-encoding': req.headers['accept-encoding'],
'x-request-id': requestId,
authorization: req.headers.authorization,
// We need that to be backwards compatible with the new Authorization header format
'X-API-Token': req.headers['x-api-token'] ?? '',
'graphql-client-name': 'Hive App',
'x-use-proxy': '/api/proxy',
'graphql-client-version': env.release,
},
method: 'GET',
} as any);
return res.send(await response.text());
}
let accessToken: string | undefined;
try {
accessToken = await extractAccessTokenFromRequest(req, res);
} catch (error) {
captureException(error);
logger.error(error);
}
// For convenience, we allow to pass the access token in the Authorization header
// in development mode to avoid spinning up multiple proxy servers when testing integrations
if (env.nodeEnv === 'development' && !accessToken && req.headers['authorization']) {
accessToken = req.headers['authorization'].replace('Bearer ', '');
}
if (!accessToken) {
res.status(401).json({});
return;
}
try {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
'content-type': req.headers['content-type'],
accept: req.headers['accept'],
'accept-encoding': req.headers['accept-encoding'],
'x-request-id': requestId,
'X-API-Token': req.headers['x-api-token'] ?? '',
'graphql-client-name': 'Hive App',
'graphql-client-version': env.release,
},
method: 'POST',
body: JSON.stringify(req.body || {}),
} as any);
const xRequestId = response.headers.get('x-request-id');
if (xRequestId) {
res.setHeader('x-request-id', xRequestId);
}
const parsedData = await response.json();
res.status(200).json(parsedData);
} catch (error) {
captureException(error);
logger.error(error);
// TODO: better type narrowing of the error
const status = (error as Record<string, number | undefined>)?.['status'] ?? 500;
const code = (error as Record<string, unknown | undefined>)?.['code'] ?? '';
const message = (error as Record<string, unknown | undefined>)?.['message'] ?? '';
res.setHeader('x-request-id', requestId);
res.status(status).json({
code,
error: message,
});
}
}
export default graphql;
export const config = {
api: {
bodyParser: {
sizeLimit: '10mb',
},
externalResolver: true,
},
};

View file

@ -49,7 +49,7 @@ export default async function slackCallback(req: NextApiRequest, res: NextApiRes
const token = slackResponse.access_token;
await graphql({
url: `${env.appBaseUrl}/api/proxy`,
url: env.graphqlPublicEndpoint,
headers: {
...req.headers,
'content-type': 'application/json',

View file

@ -1,8 +1,9 @@
import { ReactElement } from 'react';
import { ReactElement, useEffect, useState } from 'react';
import { GraphiQL } from 'graphiql';
import { HiveLogo } from '@/components/v2/icon';
import { createGraphiQLFetcher } from '@graphiql/toolkit';
import 'graphiql/graphiql.css';
import { env } from '@/env/frontend';
export default function DevPage(): ReactElement {
return (
@ -13,13 +14,27 @@ export default function DevPage(): ReactElement {
--color-primary: 40, 89%, 60%;
}
`}</style>
{process.browser && (
<GraphiQL fetcher={createGraphiQLFetcher({ url: `${location.origin}/api/proxy` })}>
<GraphiQL.Logo>
<HiveLogo className="size-6" />
</GraphiQL.Logo>
</GraphiQL>
)}
<Editor />
</div>
);
}
function Editor() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
if (!isClient) {
return null;
}
return (
<GraphiQL fetcher={createGraphiQLFetcher({ url: env.graphqlPublicEndpoint })}>
<GraphiQL.Logo>
<HiveLogo className="size-6" />
</GraphiQL.Logo>
</GraphiQL>
);
}

View file

@ -1,104 +1,50 @@
import { ReactElement, useEffect } from 'react';
import { NextApiRequest, NextApiResponse } from 'next';
import Cookies from 'cookies';
import Session from 'supertokens-node/recipe/session';
import { useQuery } from 'urql';
import { authenticated } from '@/components/authenticated-container';
import { Title } from '@/components/common';
import { DataWrapper } from '@/components/v2';
import { LAST_VISITED_ORG_KEY } from '@/constants';
import { env } from '@/env/backend';
import { QueryError } from '@/components/ui/query-error';
import { HiveLogo } from '@/components/v2/icon';
import { graphql } from '@/gql';
import { writeLastVisitedOrganization } from '@/lib/cookies';
import { useRouteSelector } from '@/lib/hooks/use-route-selector';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { getLogger } from '@/server-logger';
import { type InternalApi } from '@hive/server';
import { createTRPCProxyClient, httpLink } from '@trpc/client';
import { useLastVisitedOrganizationWriter } from '@/lib/last-visited-org';
async function getSuperTokensUserIdFromRequest(
req: NextApiRequest,
res: NextApiResponse,
): Promise<string | null> {
const session = await Session.getSession(req, res, { sessionRequired: false });
return session?.getUserId() ?? null;
}
export const getServerSideProps = withSessionProtection(async ({ req, res }) => {
const logger = getLogger(req);
const internalApi = createTRPCProxyClient<InternalApi>({
links: [httpLink({ url: `${env.serverEndpoint}/trpc` })],
});
const superTokensId = await getSuperTokensUserIdFromRequest(req as any, res as any);
try {
const cookies = new Cookies(req, res);
const lastOrgIdInCookies = cookies.get(LAST_VISITED_ORG_KEY) ?? null;
if (superTokensId) {
const defaultOrganization = await internalApi.getDefaultOrgForUser.query({
superTokensUserId: superTokensId,
lastOrgId: lastOrgIdInCookies,
});
if (defaultOrganization) {
writeLastVisitedOrganization(req, res, defaultOrganization.cleanId);
return {
redirect: {
destination: `/${defaultOrganization.cleanId}`,
permanent: false,
},
};
}
return {
redirect: {
destination: '/org/new',
permanent: true,
},
};
}
} catch (error) {
logger.error(error);
}
return {
props: {},
};
});
export const OrganizationsQuery = graphql(`
query organizations {
organizations {
nodes {
...OrganizationFields
export const DefaultOrganizationQuery = graphql(`
query myDefaultOrganization($previouslyVisitedOrganizationId: ID) {
myDefaultOrganization(previouslyVisitedOrganizationId: $previouslyVisitedOrganizationId) {
organization {
id
cleanId
}
total
}
}
`);
function Home(): ReactElement {
const [query] = useQuery({ query: OrganizationsQuery });
const [query] = useQuery({ query: DefaultOrganizationQuery });
const router = useRouteSelector();
const result = query.data?.myDefaultOrganization;
useLastVisitedOrganizationWriter(result?.organization?.cleanId);
useEffect(() => {
// Just in case server-side redirect wasn't working
if (query.data) {
const org = query.data.organizations.nodes[0];
if (result === null) {
// No organization, redirect to create one
void router.push('/org/new');
} else if (result?.organization.cleanId) {
// Redirect to the organization
void router.visitOrganization({ organizationId: result.organization.cleanId });
} // else, still loading
}, [router, result]);
if (org) {
void router.visitOrganization({ organizationId: org.cleanId });
}
}
}, [router, query.data]);
if (query.error) {
return <QueryError error={query.error} />;
}
return (
<>
<Title title="Home" />
<DataWrapper query={query}>{() => <></>}</DataWrapper>
<div className="flex size-full flex-row items-center justify-center">
<HiveLogo className="size-16 animate-pulse" />
</div>
</>
);
}

View file

@ -6,7 +6,6 @@ import { Button, DataWrapper } from '@/components/v2';
import { graphql } from '@/gql';
import { useNotifications } from '@/lib/hooks/use-notifications';
import { useRouteSelector } from '@/lib/hooks/use-route-selector';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { cn } from '@/lib/utils';
const classes = {
@ -131,6 +130,4 @@ function JoinOrganizationPage() {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(JoinOrganizationPage);

View file

@ -2,12 +2,6 @@ import React from 'react';
import { useRouter } from 'next/router';
import { signOut } from 'supertokens-auth-react/recipe/thirdpartyemailpassword';
export function getServerSideProps() {
return {
props: {},
};
}
export default function LogOutPage() {
const router = useRouter();
React.useEffect(() => {

View file

@ -6,7 +6,6 @@ import { Page } from '@/components/common';
import { DATE_RANGE_OPTIONS, floorToMinute } from '@/components/common/TimeFilter';
import { Checkbox as RadixCheckbox, RadixSelect, Tooltip } from '@/components/v2';
import { subDays } from '@/lib/date-time';
import { withSessionProtection } from '@/lib/supertokens/guard';
type DateRangeOptions = Exclude<(typeof DATE_RANGE_OPTIONS)[number], { key: 'all' }>;
@ -122,6 +121,4 @@ function Manage() {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(Manage);

View file

@ -5,7 +5,6 @@ import { authenticated } from '@/components/authenticated-container';
import { Button } from '@/components/ui/button';
import { MetaTitle } from '@/components/v2';
import { CreateOrganizationForm } from '@/components/v2/modals/create-organization';
import { withSessionProtection } from '@/lib/supertokens/guard';
function CreateOrgPage(): ReactElement {
const router = useRouter();
@ -28,6 +27,4 @@ function CreateOrgPage(): ReactElement {
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(CreateOrgPage);

View file

@ -1,36 +1,13 @@
import { ReactElement, useEffect } from 'react';
import { useRouter } from 'next/router';
import Session, { SessionAuth } from 'supertokens-auth-react/recipe/session';
import { ReactElement } from 'react';
import { SessionAuth } from 'supertokens-auth-react/recipe/session';
import { HiveStripeWrapper } from '@/lib/billing/stripe';
/**
* Utility for wrapping a component with an authenticated container that has the default application layout.
*/
export const authenticated =
<TProps extends { fromSupertokens?: 'needs-refresh' }>(
Component: (props: Omit<TProps, 'fromSupertokens'>) => ReactElement | null,
) =>
<TProps extends {}>(Component: (props: TProps) => ReactElement | null) =>
(props: TProps) => {
const router = useRouter();
useEffect(() => {
async function doRefresh() {
if (props.fromSupertokens === 'needs-refresh') {
if (await Session.attemptRefreshingSession()) {
location.reload();
} else {
void router.replace(`/auth?redirectToPath=${router.asPath}`);
}
}
}
void doRefresh();
}, []);
if (props.fromSupertokens) {
return null;
}
return (
<SessionAuth>
<HiveStripeWrapper>

View file

@ -15,6 +15,7 @@ import {
} from '@/lib/access/organization';
import { getIsStripeEnabled } from '@/lib/billing/stripe-public-key';
import { useRouteSelector, useToggle } from '@/lib/hooks';
import { useLastVisitedOrganizationWriter } from '@/lib/last-visited-org';
import { ProPlanBilling } from '../organization/billing/ProPlanBillingWarm';
import { RateLimitWarn } from '../organization/billing/RateLimitWarn';
@ -86,6 +87,8 @@ export function OrganizationLayout({
redirect: true,
});
useLastVisitedOrganizationWriter(currentOrganization?.cleanId);
const meInCurrentOrg = currentOrganization?.me;
const me = useFragment(OrganizationLayout_MeFragment, props.me);
const organizationConnection = useFragment(
@ -226,7 +229,7 @@ export function OrganizationLayout({
</Tabs.List>
</Tabs>
) : (
<div className="flex flex-row gap-x-8 border-b-[2px] border-b-transparent px-4 py-3">
<div className="flex flex-row gap-x-8 border-b-2 border-b-transparent px-4 py-3">
<div className="h-5 w-12 animate-pulse rounded-full bg-gray-800" />
<div className="h-5 w-12 animate-pulse rounded-full bg-gray-800" />
<div className="h-5 w-12 animate-pulse rounded-full bg-gray-800" />

View file

@ -9,6 +9,7 @@ import { CreateTargetModal } from '@/components/v2/modals';
import { FragmentType, graphql, useFragment } from '@/gql';
import { canAccessProject, ProjectAccessScope, useProjectAccess } from '@/lib/access/project';
import { useRouteSelector, useToggle } from '@/lib/hooks';
import { useLastVisitedOrganizationWriter } from '@/lib/last-visited-org';
import { ProjectMigrationToast } from '../project/migration-toast';
export enum Page {
@ -101,6 +102,8 @@ export function ProjectLayout({
redirect: true,
});
useLastVisitedOrganizationWriter(currentOrganization?.cleanId);
const me = useFragment(ProjectLayout_MeFragment, props.me);
const organizationConnection = useFragment(
ProjectLayout_OrganizationConnectionFragment,
@ -239,7 +242,7 @@ export function ProjectLayout({
</Tabs.List>
</Tabs>
) : (
<div className="flex flex-row gap-x-8 border-b-[2px] border-b-transparent px-4 py-3">
<div className="flex flex-row gap-x-8 border-b-2 border-b-transparent px-4 py-3">
<div className="h-5 w-12 animate-pulse rounded-full bg-gray-800" />
<div className="h-5 w-12 animate-pulse rounded-full bg-gray-800" />
<div className="h-5 w-12 animate-pulse rounded-full bg-gray-800" />

View file

@ -9,6 +9,7 @@ import { ConnectSchemaModal } from '@/components/v2/modals';
import { FragmentType, graphql, useFragment } from '@/gql';
import { canAccessTarget, TargetAccessScope, useTargetAccess } from '@/lib/access/target';
import { useRouteSelector, useToggle } from '@/lib/hooks';
import { useLastVisitedOrganizationWriter } from '@/lib/last-visited-org';
import { cn } from '@/lib/utils';
import { ProjectMigrationToast } from '../project/migration-toast';
@ -131,6 +132,8 @@ export const TargetLayout = ({
redirect: true,
});
useLastVisitedOrganizationWriter(currentOrganization?.cleanId);
const canAccessSchema = canAccessTarget(
TargetAccessScope.RegistryRead,
currentOrganization?.me ?? null,
@ -333,7 +336,7 @@ export const TargetLayout = ({
</Tabs.List>
</Tabs>
) : (
<div className="flex flex-row gap-x-8 border-b-[2px] border-b-transparent px-4 py-3">
<div className="flex flex-row gap-x-8 border-b-2 border-b-transparent px-4 py-3">
<div className="h-5 w-12 animate-pulse rounded-full bg-gray-800" />
<div className="h-5 w-12 animate-pulse rounded-full bg-gray-800" />
<div className="h-5 w-12 animate-pulse rounded-full bg-gray-800" />

View file

@ -37,6 +37,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useToast } from '@/components/ui/use-toast';
import { ProductUpdatesLink } from '@/components/v2/docs-note';
import { env } from '@/env/frontend';
import { FragmentType, graphql, useFragment } from '@/gql';
import { OrganizationAccessScope, ProjectAccessScope, TargetAccessScope } from '@/gql/graphql';
import { Scope, scopes } from '@/lib/access/common';
@ -69,7 +70,7 @@ export function MemberRoleMigrationStickyNote(props: {
const isAdmin = organization?.me.isAdmin;
const unassignedMembersToMigrateCount = organization?.unassignedMembersToMigrate.length;
const migrationDeadline = __frontend_env.migrations.member_roles_deadline;
const migrationDeadline = env.migrations.member_roles_deadline;
const daysLeft = useRef<number>();
if (typeof daysLeft.current !== 'number') {
@ -205,7 +206,7 @@ function SimilarRoles(props: {
<p className="text-xs text-gray-400">
Maybe some of the existing roles are similar to the one you are about to create?
</p>
<div className="my-4 h-[1px] w-full bg-gray-900" />
<div className="my-4 h-px w-full bg-gray-900" />
<div className="space-y-4 text-sm">
{props.roles.map(role => {
const downgrade = {

View file

@ -86,6 +86,7 @@ export function PolicyListItem(props: {
<div>
<SeverityLevelToggle canTurnOff={props.overridingParentRule} rule={ruleInfo.id} />
</div>
{/* eslint-disable-next-line tailwindcss/no-unnecessary-arbitrary-value */}
<div className="grid grow grid-cols-4 align-middle [&>*]:min-h-[40px] [&>*]:border-l-[1px] [&>*]:border-l-gray-800">
{shouldShowRuleConfig && (
<PolicyRuleConfig

View file

@ -361,7 +361,7 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
</div>
</div>
{showCalendar && (
<div className="absolute left-0 top-0 translate-x-[-100%]">
<div className="absolute left-0 top-0 -translate-x-full">
<div className="bg-popover mr-1 rounded-md border p-4">
<Button
variant="ghost"

View file

@ -45,7 +45,7 @@ export const DropdownMenuContent = React.forwardRef<
flex-col
gap-1
rounded-md
bg-[#0b0d11]
bg-black
p-[13px]
text-sm
font-normal
@ -110,7 +110,7 @@ export const DropdownMenuSubContent = React.forwardRef<
flex-col
gap-1
rounded-md
bg-[#0b0d11]
bg-black
p-[13px]
text-sm
font-normal

View file

@ -38,7 +38,7 @@ const Trigger = forwardRef<any, Omit<TabsTriggerProps, 'className'> & { hasBorde
? `
radix-state-active:border-b-orange-500
cursor-pointer
border-b-[2px]
border-b-2
border-b-transparent
px-4
py-3

View file

@ -1,48 +0,0 @@
import { env as backendEnv } from '@/env/backend';
/**
* The configuration for the frontend environment.
* Don't add any sensitive values here!
* Everything here must be serializable.
*
* **NOTE** Don't import this file in your frontend code. Instead import `@/env/frontend`.
*
* You might wonder why there is an optional chaining is used here?
* It is because Next.js tries to prerender the page lol and during that time we don't have the environment variables. :)
*/
export const env = {
appBaseUrl: backendEnv.appBaseUrl,
docsUrl: backendEnv.docsUrl,
stripePublicKey: backendEnv?.stripePublicKey,
auth: {
github: !!backendEnv.auth.github,
google: !!backendEnv.auth.google,
okta: backendEnv.auth.okta ? { hidden: backendEnv.auth.okta.hidden } : null,
requireEmailVerification: backendEnv.auth.requireEmailVerification,
organizationOIDC: backendEnv.auth.organizationOIDC,
},
analytics: {
googleAnalyticsTrackingId: backendEnv?.analytics.googleAnalyticsTrackingId,
},
integrations: {
slack: !!backendEnv.slack,
},
sentry: backendEnv.sentry,
release: backendEnv.release,
environment: backendEnv.environment,
nodeEnv: backendEnv.nodeEnv,
graphql: {
persistedOperations: backendEnv.graphql.persistedOperations,
},
zendeskSupport: backendEnv.zendeskSupport,
migrations: {
member_roles_deadline: backendEnv.migrations.member_roles_deadline,
},
} as const;
declare global {
// eslint-disable-next-line no-var
var __frontend_env: typeof env;
}
globalThis['__frontend_env'] = env;

View file

@ -1,7 +1,204 @@
/**
* The environment as available on the !!!BACKEND!!!
*/
export const env = globalThis.__backend_env ?? noop();
import { PHASE_PRODUCTION_BUILD } from 'next/constants';
import zod from 'zod';
import * as Sentry from '@sentry/nextjs';
import { getAllEnv } from './read';
// Weird hacky way of getting the Sentry.Integrations object
// When the nextjs config is loaded by Next CLI Sentry has `Integrations` property.
// When nextjs starts and the `environment.js` is loaded, the Sentry object doesn't have the `Integrations` property, it' under `Sentry.default` property.
// Dealing with esm/cjs/default exports is a pain, we all feel that pain...
const Integrations =
'default' in Sentry
? ((Sentry as any).default as typeof Sentry).Integrations
: Sentry.Integrations;
// treat an empty string `''` as `undefined`
const emptyString = <T extends zod.ZodType>(input: T) => {
return zod.preprocess((value: unknown) => {
if (value === '') return undefined;
return value;
}, input);
};
const BaseSchema = zod.object({
NODE_ENV: zod.string(),
ENVIRONMENT: zod.string(),
APP_BASE_URL: zod.string().url(),
GRAPHQL_PUBLIC_ENDPOINT: zod.string().url(),
GRAPHQL_PUBLIC_ORIGIN: zod.string().url(),
INTEGRATION_GITHUB_APP_NAME: emptyString(zod.string().optional()),
GA_TRACKING_ID: emptyString(zod.string().optional()),
DOCS_URL: emptyString(zod.string().url().optional()),
STRIPE_PUBLIC_KEY: emptyString(zod.string().optional()),
RELEASE: emptyString(zod.string().optional()),
AUTH_REQUIRE_EMAIL_VERIFICATION: emptyString(
zod.union([zod.literal('1'), zod.literal('0')]).optional(),
),
GRAPHQL_PERSISTED_OPERATIONS: emptyString(
zod.union([zod.literal('1'), zod.literal('0')]).optional(),
),
ZENDESK_SUPPORT: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
});
const IntegrationSlackSchema = zod.union([
zod.object({
INTEGRATION_SLACK: emptyString(zod.literal('0').optional()),
}),
zod.object({
INTEGRATION_SLACK: zod.literal('1'),
INTEGRATION_SLACK_CLIENT_ID: zod.string(),
INTEGRATION_SLACK_CLIENT_SECRET: zod.string(),
}),
]);
const AuthGitHubConfigSchema = zod.object({
AUTH_GITHUB: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
});
const AuthGoogleConfigSchema = zod.object({
AUTH_GOOGLE: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
});
const AuthOktaConfigSchema = zod.object({
AUTH_OKTA: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
AUTH_OKTA_HIDDEN: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
});
const AuthOktaMultiTenantSchema = zod.object({
AUTH_ORGANIZATION_OIDC: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
});
const SentryConfigSchema = zod.union([
zod.object({
SENTRY: zod.union([zod.void(), zod.literal('0')]),
}),
zod.object({
SENTRY: zod.literal('1'),
SENTRY_DSN: zod.string(),
}),
]);
const MigrationsSchema = zod.object({
MEMBER_ROLES_DEADLINE: emptyString(
zod
.date({
coerce: true,
})
.optional(),
),
});
const processEnv = getAllEnv();
function buildConfig() {
const configs = {
base: BaseSchema.safeParse(processEnv),
integrationSlack: IntegrationSlackSchema.safeParse(processEnv),
sentry: SentryConfigSchema.safeParse(processEnv),
authGithub: AuthGitHubConfigSchema.safeParse(processEnv),
authGoogle: AuthGoogleConfigSchema.safeParse(processEnv),
authOkta: AuthOktaConfigSchema.safeParse(processEnv),
authOktaMultiTenant: AuthOktaMultiTenantSchema.safeParse(processEnv),
migrations: MigrationsSchema.safeParse(processEnv),
};
const environmentErrors: Array<string> = [];
for (const config of Object.values(configs)) {
if (config.success === false) {
environmentErrors.push(JSON.stringify(config.error.format(), null, 4));
}
}
if (environmentErrors.length) {
const fullError = environmentErrors.join('\n');
console.error('❌ Invalid (backend) environment variables:', fullError);
process.exit(1);
}
function extractConfig<Input, Output>(config: zod.SafeParseReturnType<Input, Output>): Output {
if (!config.success) {
throw new Error('Something went wrong.');
}
return config.data;
}
const base = extractConfig(configs.base);
const integrationSlack = extractConfig(configs.integrationSlack);
const sentry = extractConfig(configs.sentry);
const authGithub = extractConfig(configs.authGithub);
const authGoogle = extractConfig(configs.authGoogle);
const authOkta = extractConfig(configs.authOkta);
const authOktaMultiTenant = extractConfig(configs.authOktaMultiTenant);
const migrations = extractConfig(configs.migrations);
const config = {
release: base.RELEASE ?? 'local',
nodeEnv: base.NODE_ENV,
environment: base.ENVIRONMENT,
appBaseUrl: base.APP_BASE_URL.replace(/\/$/, ''),
graphqlPublicEndpoint: base.GRAPHQL_PUBLIC_ENDPOINT,
graphqlPublicOrigin: base.GRAPHQL_PUBLIC_ORIGIN,
slack:
integrationSlack.INTEGRATION_SLACK === '1'
? {
clientId: integrationSlack.INTEGRATION_SLACK_CLIENT_ID,
clientSecret: integrationSlack.INTEGRATION_SLACK_CLIENT_SECRET,
}
: null,
github: base.INTEGRATION_GITHUB_APP_NAME ? { appName: base.INTEGRATION_GITHUB_APP_NAME } : null,
analytics: {
googleAnalyticsTrackingId: base.GA_TRACKING_ID,
},
docsUrl: base.DOCS_URL,
auth: {
github: {
enabled: authGithub.AUTH_GITHUB === '1',
},
google: {
enabled: authGoogle.AUTH_GOOGLE === '1',
},
okta: {
enabled: authOkta.AUTH_OKTA === '1',
hidden: authOkta.AUTH_OKTA_HIDDEN === '1',
},
organizationOIDC: authOktaMultiTenant.AUTH_ORGANIZATION_OIDC === '1',
requireEmailVerification: base.AUTH_REQUIRE_EMAIL_VERIFICATION === '1',
},
sentry: sentry.SENTRY === '1' ? { dsn: sentry.SENTRY_DSN } : null,
stripePublicKey: base.STRIPE_PUBLIC_KEY ?? null,
graphql: {
persistedOperations: base.GRAPHQL_PERSISTED_OPERATIONS === '1',
},
zendeskSupport: base.ZENDESK_SUPPORT === '1',
migrations: {
member_roles_deadline: migrations.MEMBER_ROLES_DEADLINE ?? null,
},
} as const;
return config;
}
const isNextBuilding = processEnv['NEXT_PHASE'] === PHASE_PRODUCTION_BUILD;
export const env = !isNextBuilding ? buildConfig() : noop();
// TODO: I don't like this here, but it seems like it makes most sense here :)
Sentry.init({
serverName: 'app',
dist: 'app',
enabled: !!env.sentry,
enableTracing: false,
tracesSampleRate: 1,
dsn: env.sentry?.dsn,
release: env.release,
environment: env.environment,
integrations: [
// HTTP integration is only available on the server
new Integrations.Http({
tracing: false,
}),
],
});
/**
* Next.js is so kind and tries to pre-render our page without the environment information being available... :)

View file

@ -0,0 +1,25 @@
export const ALLOWED_ENVIRONMENT_VARIABLES = [
'NODE_ENV',
'ENVIRONMENT',
'APP_BASE_URL',
'GRAPHQL_PUBLIC_ENDPOINT',
'GRAPHQL_PUBLIC_ORIGIN',
'GA_TRACKING_ID',
'DOCS_URL',
'STRIPE_PUBLIC_KEY',
'RELEASE',
'AUTH_REQUIRE_EMAIL_VERIFICATION',
'GRAPHQL_PERSISTED_OPERATIONS',
'ZENDESK_SUPPORT',
'INTEGRATION_SLACK',
'AUTH_GITHUB',
'AUTH_GOOGLE',
'AUTH_OKTA',
'AUTH_OKTA_HIDDEN',
'AUTH_ORGANIZATION_OIDC',
'SENTRY',
'SENTRY_DSN',
'MEMBER_ROLES_DEADLINE',
] as const;
export type AllowedEnvironmentVariables = (typeof ALLOWED_ENVIRONMENT_VARIABLES)[number];

View file

@ -1,7 +1,181 @@
/**
* The environment as available on the !!!FRONT END!!!
*/
export const env = globalThis.__frontend_env ?? noop();
import { PHASE_PRODUCTION_BUILD } from 'next/constants';
import zod from 'zod';
import type { AllowedEnvironmentVariables } from './frontend-public-variables';
import { getAllEnv } from './read';
type RestrictKeys<T, K> = {
[P in keyof T]: P extends K ? T[P] : never;
};
// Makes sure the list of available environment variables (ALLOWED_ENVIRONMENT_VARIABLES in 'frontend-public-variables.ts') is in sync with the zod schema.
// If you add/remove an environment variable, make sure to modify it there as well
// Example: If `NODE_ENV` is not one of the allowed values, TypeScript will detect a type error.
//
// {
// NOT_FOUND: zod.string(),
// ^ Type 'ZodString' is not assignable to type 'never'.
// NODE_ENV: zod.string(),
// }
//
function protectedObject<
T extends {
[K in keyof T]: zod.ZodTypeAny;
},
>(shape: RestrictKeys<T, AllowedEnvironmentVariables>) {
return zod.object(shape);
}
// treat an empty string `''` as `undefined`
const emptyString = <T extends zod.ZodType>(input: T) => {
return zod.preprocess((value: unknown) => {
if (value === '') return undefined;
return value;
}, input);
};
const enabledOrDisabled = emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional());
// todo: reuse backend schema
const BaseSchema = protectedObject({
NODE_ENV: zod.string(),
ENVIRONMENT: zod.string(),
APP_BASE_URL: zod.string().url(),
GRAPHQL_PUBLIC_ENDPOINT: zod.string().url(),
GRAPHQL_PUBLIC_ORIGIN: zod.string().url(),
GA_TRACKING_ID: emptyString(zod.string().optional()),
DOCS_URL: emptyString(zod.string().url().optional()),
STRIPE_PUBLIC_KEY: emptyString(zod.string().optional()),
RELEASE: emptyString(zod.string().optional()),
AUTH_REQUIRE_EMAIL_VERIFICATION: emptyString(
zod.union([zod.literal('1'), zod.literal('0')]).optional(),
),
GRAPHQL_PERSISTED_OPERATIONS: emptyString(
zod.union([zod.literal('1'), zod.literal('0')]).optional(),
),
ZENDESK_SUPPORT: enabledOrDisabled,
});
const IntegrationSlackSchema = protectedObject({
INTEGRATION_SLACK: enabledOrDisabled,
});
const AuthGitHubConfigSchema = protectedObject({
AUTH_GITHUB: enabledOrDisabled,
});
const AuthGoogleConfigSchema = protectedObject({
AUTH_GOOGLE: enabledOrDisabled,
});
const AuthOktaConfigSchema = protectedObject({
AUTH_OKTA: enabledOrDisabled,
AUTH_OKTA_HIDDEN: enabledOrDisabled,
});
const AuthOktaMultiTenantSchema = protectedObject({
AUTH_ORGANIZATION_OIDC: enabledOrDisabled,
});
const SentryConfigSchema = zod.union([
protectedObject({
SENTRY: zod.union([zod.void(), zod.literal('0')]),
}),
protectedObject({
SENTRY: zod.literal('1'),
SENTRY_DSN: zod.string(),
}),
]);
const MigrationsSchema = protectedObject({
MEMBER_ROLES_DEADLINE: emptyString(
zod
.date({
coerce: true,
})
.optional(),
),
});
const envValues = getAllEnv();
function buildConfig() {
const configs = {
base: BaseSchema.safeParse(envValues),
integrationSlack: IntegrationSlackSchema.safeParse(envValues),
sentry: SentryConfigSchema.safeParse(envValues),
authGithub: AuthGitHubConfigSchema.safeParse(envValues),
authGoogle: AuthGoogleConfigSchema.safeParse(envValues),
authOkta: AuthOktaConfigSchema.safeParse(envValues),
authOktaMultiTenant: AuthOktaMultiTenantSchema.safeParse(envValues),
migrations: MigrationsSchema.safeParse(envValues),
};
const environmentErrors: Array<string> = [];
for (const config of Object.values(configs)) {
if (config.success === false) {
environmentErrors.push(JSON.stringify(config.error.format(), null, 4));
}
}
if (environmentErrors.length) {
const fullError = environmentErrors.join('\n');
console.error('❌ Invalid (frontend) environment variables:', fullError);
throw new Error('Invalid environment variables.');
}
function extractConfig<Input, Output>(config: zod.SafeParseReturnType<Input, Output>): Output {
if (!config.success) {
throw new Error('Something went wrong.');
}
return config.data;
}
const base = extractConfig(configs.base);
const integrationSlack = extractConfig(configs.integrationSlack);
const sentry = extractConfig(configs.sentry);
const authGithub = extractConfig(configs.authGithub);
const authGoogle = extractConfig(configs.authGoogle);
const authOkta = extractConfig(configs.authOkta);
const authOktaMultiTenant = extractConfig(configs.authOktaMultiTenant);
const migrations = extractConfig(configs.migrations);
return {
appBaseUrl: base.APP_BASE_URL.replace(/\/$/, ''),
graphqlPublicEndpoint: base.GRAPHQL_PUBLIC_ENDPOINT,
graphqlPublicOrigin: base.GRAPHQL_PUBLIC_ORIGIN,
docsUrl: base.DOCS_URL,
stripePublicKey: base.STRIPE_PUBLIC_KEY ?? null,
auth: {
github: authGithub.AUTH_GITHUB === '1',
google: authGoogle.AUTH_GOOGLE === '1',
okta: authOkta.AUTH_OKTA === '1' ? { hidden: authOkta.AUTH_OKTA_HIDDEN === '1' } : null,
requireEmailVerification: base.AUTH_REQUIRE_EMAIL_VERIFICATION === '1',
organizationOIDC: authOktaMultiTenant.AUTH_ORGANIZATION_OIDC === '1',
},
analytics: {
googleAnalyticsTrackingId: base.GA_TRACKING_ID,
},
integrations: {
slack: integrationSlack.INTEGRATION_SLACK === '1',
},
sentry: sentry.SENTRY === '1' ? { dsn: sentry.SENTRY_DSN } : null,
release: base.RELEASE ?? 'local',
environment: base.ENVIRONMENT,
nodeEnv: base.NODE_ENV,
graphql: {
persistedOperations: base.GRAPHQL_PERSISTED_OPERATIONS === '1',
},
zendeskSupport: base.ZENDESK_SUPPORT === '1',
migrations: {
member_roles_deadline: migrations.MEMBER_ROLES_DEADLINE ?? null,
},
} as const;
}
const isNextBuilding = envValues['NEXT_PHASE'] === PHASE_PRODUCTION_BUILD;
export const env = !isNextBuilding ? buildConfig() : noop();
/**
* Next.js is so kind and tries to pre-render our page without the environment information being available... :)

15
packages/web/app/src/env/read.ts vendored Normal file
View file

@ -0,0 +1,15 @@
function isBrowser() {
// eslint-disable-next-line no-restricted-syntax
return Boolean(
typeof window !== 'undefined' && '__ENV' in window && window['__ENV'] !== undefined,
);
}
export function getAllEnv(): Record<string, string | undefined> {
if (isBrowser()) {
return (window as any)['__ENV'] ?? {};
}
// eslint-disable-next-line no-process-env
return process.env;
}

View file

@ -1,32 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next';
import supertokens from 'supertokens-node';
import { SessionContainerInterface } from 'supertokens-node/lib/build/recipe/session/types';
import { superTokensNextWrapper } from 'supertokens-node/nextjs';
import { verifySession } from 'supertokens-node/recipe/session/framework/express';
import { backendConfig } from '@/config/supertokens/backend';
supertokens.init(backendConfig());
export async function extractAccessTokenFromRequest(
req: NextApiRequest,
res: NextApiResponse,
): Promise<string> {
await superTokensNextWrapper(
async next =>
await verifySession({
sessionRequired: false,
checkDatabase: true,
})(req as any, res as any, next),
req,
res,
);
const { session } = req as { session?: SessionContainerInterface };
// Session can be undefined in case no access token was sent.
const accessToken = session?.getAccessToken() ?? null;
if (accessToken === null) {
throw new Error('Missing access token.');
}
return accessToken;
}

View file

@ -1,9 +0,0 @@
import Cookies from 'cookies';
import { LAST_VISITED_ORG_KEY } from '@/constants';
export function writeLastVisitedOrganization(req: any, res: any, orgId: string): void {
const cookies = new Cookies(req, res);
cookies.set(LAST_VISITED_ORG_KEY, orgId, {
httpOnly: false,
});
}

View file

@ -0,0 +1,11 @@
import { useEffect } from 'react';
import Cookies from 'js-cookie';
import { LAST_VISITED_ORG_KEY } from '@/constants';
export function useLastVisitedOrganizationWriter(orgId?: string | null) {
useEffect(() => {
if (orgId) {
Cookies.set(LAST_VISITED_ORG_KEY, orgId);
}
}, [orgId]);
}

View file

@ -1,14 +1,14 @@
import { env } from '@/env/frontend';
export const appInfo = () => {
const { appBaseUrl } = env;
const { appBaseUrl, graphqlPublicOrigin } = env;
return {
// learn more about this on https://supertokens.com/docs/thirdpartyemailpassword/appinfo
appName: 'GraphQL Hive',
apiDomain: appBaseUrl,
apiDomain: graphqlPublicOrigin,
websiteDomain: appBaseUrl,
apiBasePath: '/api/auth',
apiBasePath: '/auth-api',
websiteBasePath: '/auth',
};
};

View file

@ -7,13 +7,13 @@ const supertokenRoutes = new Set([
'/auth',
]);
if (env.auth.github) {
if (env.auth.github.enabled) {
supertokenRoutes.add('/auth/callback/github');
}
if (env.auth.google) {
if (env.auth.google.enabled) {
supertokenRoutes.add('/auth/callback/google');
}
if (env.auth.okta) {
if (env.auth.okta.enabled) {
supertokenRoutes.add('/auth/callback/okta');
}
if (env.auth.organizationOIDC) {

View file

@ -1,67 +0,0 @@
import { GetServerSideProps } from 'next';
import { SessionContainerInterface } from 'supertokens-node/lib/build/recipe/session/types';
import { captureException } from '@sentry/nextjs';
const serverSidePropsSessionHandling = async (context: Parameters<GetServerSideProps>[0]) => {
const { backendConfig } = await import('@/config/supertokens/backend');
const SupertokensNode = await import('supertokens-node');
const Session = await import('supertokens-node/recipe/session');
SupertokensNode.init(backendConfig());
let session: SessionContainerInterface | undefined;
try {
session = await Session.getSession(context.req, context.res, { sessionRequired: false });
// TODO: better error decoding :)
} catch (err: any) {
// Check whether the email is already verified.
// If it is not then we need to redirect to the email verification page - which will trigger the email sending.
if (err.type === Session.Error.INVALID_CLAIMS) {
return {
redirect: {
destination: '/auth/verify-email',
permanent: false,
},
};
}
if (err.type === Session.Error.TRY_REFRESH_TOKEN || err.type === Session.Error.UNAUTHORISED) {
return { props: { fromSupertokens: 'needs-refresh' } };
}
captureException(err);
throw err;
}
if (session === undefined) {
return {
redirect: {
destination: `/auth?redirectToPath=${encodeURIComponent(context.resolvedUrl)}`,
permanent: false,
},
};
}
return null;
};
function defaultHandler() {
return Promise.resolve({ props: {} });
}
/**
* Utility for protecting a server side props function with session handling.
* Redirects user to the login page in case there is no session.
*/
export function withSessionProtection(handlerFn: GetServerSideProps = defaultHandler) {
const getServerSideProps: GetServerSideProps = async context => {
const result = await serverSidePropsSessionHandling(context);
if (result) {
return result;
}
return handlerFn(context);
};
return getServerSideProps;
}

View file

@ -10,7 +10,6 @@ import type { CreateOperationMutationType } from '@/components/target/laboratory
import type { DeleteCollectionMutationType } from '@/components/target/laboratory/delete-collection-modal';
import type { DeleteOperationMutationType } from '@/components/target/laboratory/delete-operation-modal';
import type { CreateAccessToken_CreateTokenMutation } from '@/components/v2/modals/create-access-token';
import type { CreateOrganizationMutation } from '@/components/v2/modals/create-organization';
import type { CreateProjectMutation } from '@/components/v2/modals/create-project';
import type { CreateTarget_CreateTargetMutation } from '@/components/v2/modals/create-target';
import type { DeleteOrganizationDocument } from '@/components/v2/modals/delete-organization';
@ -19,7 +18,6 @@ import { type DeleteTargetMutation } from '@/components/v2/modals/delete-target'
import { graphql } from '@/gql';
import { ResultOf, VariablesOf } from '@graphql-typed-document-node/core';
import { Cache, QueryInput, UpdateResolver } from '@urql/exchange-graphcache';
import { OrganizationsQuery } from '../../pages';
import { CollectionsQuery } from '../../pages/[organizationId]/[projectId]/[targetId]/laboratory';
import {
TokensDocument,
@ -74,28 +72,6 @@ const deleteAlerts: TypedDocumentNodeUpdateResolver<
}
};
const createOrganization: TypedDocumentNodeUpdateResolver<typeof CreateOrganizationMutation> = (
{ createOrganization },
_args,
cache,
) => {
updateQuery(
cache,
{
query: OrganizationsQuery,
},
data => {
if (createOrganization.ok) {
data.organizations.nodes.unshift(
// TODO: figure out masking
createOrganization.ok.createdOrganizationPayload.organization as any,
);
data.organizations.total += 1;
}
},
);
};
const deleteOrganization: TypedDocumentNodeUpdateResolver<typeof DeleteOrganizationDocument> = (
{ deleteOrganization },
_args,
@ -362,7 +338,6 @@ const createOperationInDocumentCollection: TypedDocumentNodeUpdateResolver<
// UpdateResolver
export const Mutation = {
createOrganization,
deleteOrganization,
createProject,
deleteProject,

View file

@ -1,6 +1,8 @@
import { createClient, errorExchange, fetchExchange } from 'urql';
import Session from 'supertokens-auth-react/recipe/session';
import { createClient, fetchExchange } from 'urql';
import { env } from '@/env/frontend';
import schema from '@/gql/schema';
import { authExchange } from '@urql/exchange-auth';
import { cacheExchange } from '@urql/exchange-graphcache';
import { persistedExchange } from '@urql/exchange-persisted';
import { Mutation } from './urql-cache';
@ -8,12 +10,18 @@ import { networkStatusExchange } from './urql-exchanges/state';
const noKey = (): null => null;
const SERVER_BASE_PATH = '/api/proxy';
const SERVER_BASE_PATH = env.graphqlPublicEndpoint;
const isSome = <T>(value: T | null | undefined): value is T => value != null;
export const urqlClient = createClient({
url: SERVER_BASE_PATH,
fetchOptions: {
headers: {
'graphql-client-name': 'Hive App',
'graphql-client-version': env.release,
},
},
exchanges: [
cacheExchange({
schema,
@ -54,12 +62,44 @@ export const urqlClient = createClient({
globalIDs: ['SuccessfulSchemaCheck', 'FailedSchemaCheck'],
}),
networkStatusExchange,
errorExchange({
onError(error) {
if (error.response?.status === 401) {
window.location.href = '/logout';
}
},
authExchange(async () => {
let action: 'NEEDS_REFRESH' | 'VERIFY_EMAIL' | 'UNAUTHENTICATED' = 'UNAUTHENTICATED';
return {
addAuthToOperation(operation) {
return operation;
},
willAuthError() {
return false;
},
didAuthError(error) {
if (error.graphQLErrors.some(e => e.extensions?.code === 'UNAUTHENTICATED')) {
action = 'UNAUTHENTICATED';
return true;
}
if (error.graphQLErrors.some(e => e.extensions?.code === 'VERIFY_EMAIL')) {
action = 'VERIFY_EMAIL';
return true;
}
if (error.graphQLErrors.some(e => e.extensions?.code === 'NEEDS_REFRESH')) {
action = 'NEEDS_REFRESH';
return true;
}
return false;
},
async refreshAuth() {
if (action === 'NEEDS_REFRESH' && (await Session.attemptRefreshingSession())) {
location.reload();
} else if (action === 'VERIFY_EMAIL') {
window.location.href = '/auth/verify-email';
} else {
window.location.href = `/auth?redirectToPath=${encodeURIComponent(window.location.pathname)}`;
}
},
};
}),
env.graphql.persistedOperations
? persistedExchange({

View file

@ -16,6 +16,6 @@
"jsx": "preserve",
"incremental": true
},
"include": ["next-env.d.ts", "modules.d.ts", "src", "pages", "environment.ts"],
"include": ["next-env.d.ts", "modules.d.ts", "src", "pages"],
"exclude": ["node_modules"]
}

View file

@ -186,19 +186,19 @@ function Feature(props: {
<div className={cn(classes.feature, 'relative overflow-hidden')}>
<div>
<div
className="absolute top-0 h-[1px] w-full opacity-25"
className="absolute top-0 h-px w-full opacity-25"
style={{
backgroundImage: `linear-gradient(90deg, ${end}, ${start})`,
}}
/>
<div
className="absolute left-[-200px] top-[-200px] h-[255px] w-[60vw] opacity-[0.15] blur-3xl"
className="absolute left-[-200px] top-[-200px] h-[255px] w-[60vw] opacity-15 blur-3xl"
style={{
backgroundImage: `linear-gradient(180deg, ${end}, ${start})`,
}}
/>
<div
className="absolute right-[-200px] top-[-200px] h-[255px] w-[60vw] opacity-[0.15] blur-3xl"
className="absolute right-[-200px] top-[-200px] h-[255px] w-[60vw] opacity-15 blur-3xl"
style={{
backgroundImage: `linear-gradient(180deg, ${start}, ${end})`,
}}
@ -364,7 +364,7 @@ export function IndexPage(): ReactElement {
</Hero>
<div className="relative even:bg-gray-50">
<div>
<div className="absolute top-0 h-[1px] w-full bg-gradient-to-r from-gray-300 via-gray-500 to-gray-300 opacity-25" />
<div className="absolute top-0 h-px w-full bg-gradient-to-r from-gray-300 via-gray-500 to-gray-300 opacity-25" />
</div>
<StatsList>
<StatsItem label="Happy users" value={4.1} suffix="K" decimal />
@ -407,9 +407,9 @@ export function IndexPage(): ReactElement {
/>
<div className={cn(classes.feature, 'relative overflow-hidden')}>
<div>
<div className="absolute top-0 h-[1px] w-full bg-gradient-to-r from-gray-300 via-gray-500 to-gray-300 opacity-25" />
<div className="absolute left-[-200px] top-[-200px] h-[255px] w-[60vw] bg-gradient-to-b from-gray-50 to-gray-300 opacity-[0.15] blur-3xl" />
<div className="absolute right-[-200px] top-[-200px] h-[255px] w-[60vw] bg-gradient-to-b from-gray-300 to-gray-50 opacity-[0.15] blur-3xl" />
<div className="absolute top-0 h-px w-full bg-gradient-to-r from-gray-300 via-gray-500 to-gray-300 opacity-25" />
<div className="absolute left-[-200px] top-[-200px] h-[255px] w-[60vw] bg-gradient-to-b from-gray-50 to-gray-300 opacity-15 blur-3xl" />
<div className="absolute right-[-200px] top-[-200px] h-[255px] w-[60vw] bg-gradient-to-b from-gray-300 to-gray-50 opacity-15 blur-3xl" />
</div>
<div className="py-24">
<h2 className="base:mr-1 mb-12 ml-1 text-center text-3xl font-semibold leading-normal tracking-tight text-black">
@ -517,7 +517,7 @@ export function IndexPage(): ReactElement {
}}
>
<div>
<div className="absolute top-0 h-[1px] w-full bg-blue-900 opacity-25" />
<div className="absolute top-0 h-px w-full bg-blue-900 opacity-25" />
</div>
<div className="py-24">
<div className="mx-auto max-w-lg text-center text-white">
@ -543,9 +543,9 @@ export function IndexPage(): ReactElement {
</div>
<div className={cn(classes.feature, 'relative overflow-hidden')}>
<div>
<div className="absolute top-0 h-[1px] w-full bg-gradient-to-r from-gray-300 via-gray-500 to-gray-300 opacity-25" />
<div className="absolute left-[-200px] top-[-200px] h-[255px] w-[60vw] bg-gradient-to-b from-gray-600 to-gray-900 opacity-[0.15] blur-3xl" />
<div className="absolute right-[-200px] top-[-200px] h-[255px] w-[60vw] bg-gradient-to-b from-gray-900 to-gray-600 opacity-[0.15] blur-3xl" />
<div className="absolute top-0 h-px w-full bg-gradient-to-r from-gray-300 via-gray-500 to-gray-300 opacity-25" />
<div className="absolute left-[-200px] top-[-200px] h-[255px] w-[60vw] bg-gradient-to-b from-gray-600 to-gray-900 opacity-15 blur-3xl" />
<div className="absolute right-[-200px] top-[-200px] h-[255px] w-[60vw] bg-gradient-to-b from-gray-900 to-gray-600 opacity-15 blur-3xl" />
</div>
<div className="py-24">
<h2 className="mb-12 text-center text-3xl font-semibold leading-normal tracking-tight text-black">
@ -579,19 +579,19 @@ export function IndexPage(): ReactElement {
<div className={cn(classes.feature, 'relative overflow-hidden')}>
<div>
<div
className="absolute top-0 h-[1px] w-full opacity-25"
className="absolute top-0 h-px w-full opacity-25"
style={{
backgroundImage: `linear-gradient(90deg, ${gradients[3][1]}, ${gradients[3][0]})`,
}}
/>
<div
className="absolute left-[-200px] top-[-200px] h-[255px] w-[60vw] opacity-[0.15] blur-3xl"
className="absolute left-[-200px] top-[-200px] h-[255px] w-[60vw] opacity-15 blur-3xl"
style={{
backgroundImage: `linear-gradient(180deg, ${gradients[3][0]}, ${gradients[3][1]})`,
}}
/>
<div
className="absolute right-[-200px] top-[-200px] h-[255px] w-[60vw] opacity-[0.15] blur-3xl"
className="absolute right-[-200px] top-[-200px] h-[255px] w-[60vw] opacity-15 blur-3xl"
style={{
backgroundImage: `linear-gradient(180deg, ${gradients[3][1]}, ${gradients[3][0]})`,
}}

View file

@ -105,7 +105,7 @@ const OPERATIONS_EXPLAINER = 'GraphQL operations reported to GraphQL Hive';
export function Pricing({ gradient }: { gradient: [string, string] }): ReactElement {
return (
<div className="w-full bg-neutral-900">
<div className="mx-auto my-12 box-border w-full max-w-[1024px] px-6">
<div className="mx-auto my-12 box-border w-full max-w-screen-lg px-6">
<a id="pricing">
<h2
className="bg-clip-text text-2xl font-bold leading-normal text-transparent md:text-3xl"

View file

@ -1054,6 +1054,12 @@ importers:
'@escape.tech/graphql-armor-max-tokens':
specifier: 2.3.0
version: 2.3.0
'@fastify/cors':
specifier: 9.0.1
version: 9.0.1
'@fastify/formbody':
specifier: 7.4.0
version: 7.4.0
'@graphql-hive/client':
specifier: workspace:*
version: link:../../libraries/client/dist
@ -1084,6 +1090,9 @@ importers:
'@swc/core':
specifier: 1.4.8
version: 1.4.8
'@trpc/client':
specifier: 10.45.2
version: 10.45.2(@trpc/server@10.45.2)
'@trpc/server':
specifier: 10.45.2
version: 10.45.2
@ -1117,6 +1126,12 @@ importers:
reflect-metadata:
specifier: 0.2.1
version: 0.2.1
supertokens-js-override:
specifier: 0.0.4
version: 0.0.4
supertokens-node:
specifier: 15.2.1
version: 15.2.1
tslib:
specifier: 2.6.2
version: 2.6.2
@ -1586,6 +1601,9 @@ importers:
'@urql/core':
specifier: 4.1.4
version: 4.1.4(graphql@16.8.1)
'@urql/exchange-auth':
specifier: 2.1.6
version: 2.1.6(graphql@16.8.1)
'@urql/exchange-graphcache':
specifier: 6.3.3
version: 6.3.3(graphql@16.8.1)
@ -1706,12 +1724,6 @@ importers:
supertokens-auth-react:
specifier: 0.35.6
version: 0.35.6(react-dom@18.2.0)(react@18.2.0)(supertokens-web-js@0.8.0)
supertokens-js-override:
specifier: 0.0.4
version: 0.0.4
supertokens-node:
specifier: 15.2.1
version: 15.2.1
supertokens-web-js:
specifier: 0.8.0
version: 0.8.0
@ -5715,6 +5727,13 @@ packages:
fast-json-stringify: 5.12.0
dev: true
/@fastify/formbody@7.4.0:
resolution: {integrity: sha512-H3C6h1GN56/SMrZS8N2vCT2cZr7mIHzBHzOBa5OPpjfB/D6FzP9mMpE02ZzrFX0ANeh0BAJdoXKOF2e7IbV+Og==}
dependencies:
fast-querystring: 1.1.2
fastify-plugin: 4.5.1
dev: true
/@fastify/merge-json-schemas@0.1.1:
resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==}
dependencies:
@ -13994,6 +14013,15 @@ packages:
- graphql
dev: false
/@urql/exchange-auth@2.1.6(graphql@16.8.1):
resolution: {integrity: sha512-snOlt7p5kYq0KnPDuXkKe2qW3/BucQZOElvTeo3svLQuk9JiNJVnm6ffQ6QGiGO+G3AtMrctnno1+X44fLtDuQ==}
dependencies:
'@urql/core': 4.1.4(graphql@16.8.1)
wonka: 6.3.4
transitivePeerDependencies:
- graphql
dev: false
/@urql/exchange-graphcache@6.3.3(graphql@16.8.1):
resolution: {integrity: sha512-uD8zzNIrxQHYCSgfIwYxzEmU1Ml4nJ6NTKwrDlpKmTLJa3aYuG3AoiO138HZBK1XGJ2QzV5yQPfcZsmbVFH8Yg==}
dependencies:
@ -15077,6 +15105,7 @@ packages:
follow-redirects: 1.15.4(debug@4.3.4)
transitivePeerDependencies:
- debug
dev: true
/axios@1.6.2:
resolution: {integrity: sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==}
@ -15544,6 +15573,7 @@ packages:
/buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
dev: true
/buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@ -16680,6 +16710,7 @@ packages:
/cookie@0.4.0:
resolution: {integrity: sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==}
engines: {node: '>= 0.6'}
dev: true
/cookie@0.5.0:
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
@ -16891,6 +16922,7 @@ packages:
node-fetch: 2.6.12
transitivePeerDependencies:
- encoding
dev: true
/cross-inspect@1.0.0:
resolution: {integrity: sha512-4PFfn4b5ZN6FMNGSZlyb7wUhuN8wvj8t/VQHZdM4JsDcruGJ8L2kf9zao98QIrBPFCpdk27qst/AGTl7pL3ypQ==}
@ -16944,6 +16976,7 @@ packages:
/crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
dev: true
/crypto-random-string@2.0.0:
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
@ -18051,6 +18084,7 @@ packages:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
dependencies:
safe-buffer: 5.2.1
dev: true
/echarts-for-react@3.0.2(echarts@5.5.0)(react@18.2.0):
resolution: {integrity: sha512-DRwIiTzx8JfwPOVgGttDytBqdp5VzCSyMRIxubgU/g2n9y3VLUmF2FK7Icmg/sNVkv4+rktmrLN9w22U2yy3fA==}
@ -19796,6 +19830,7 @@ packages:
optional: true
dependencies:
debug: 4.3.4(supports-color@8.1.1)
dev: true
/for-each@0.3.3:
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
@ -21572,6 +21607,7 @@ packages:
/inflation@2.0.0:
resolution: {integrity: sha512-m3xv4hJYR2oXw4o4Y5l6P5P16WYmazYof+el6Al3f+YlggGj6qT9kImBAnzDelRALnP5d3h4jGBPKzYCizjZZw==}
engines: {node: '>= 0.8.0'}
dev: true
/inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
@ -22397,6 +22433,7 @@ packages:
/jose@4.14.4:
resolution: {integrity: sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==}
dev: true
/joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
@ -22640,6 +22677,7 @@ packages:
lodash: 4.17.21
ms: 2.1.3
semver: 7.5.4
dev: true
/jsox@1.2.118:
resolution: {integrity: sha512-ubYWn4WOc7HA7icvcQuIni1I7Xx4bI4KbRXbXzlr5e48hvdizeAbflBx97B629ZNH5RZnQ657Z5Z8dFgxFVrSQ==}
@ -22697,12 +22735,14 @@ packages:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
dev: true
/jws@3.2.2:
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
dependencies:
jwa: 1.4.1
safe-buffer: 5.2.1
dev: true
/kafkajs@2.2.4:
resolution: {integrity: sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==}
@ -22862,6 +22902,7 @@ packages:
/libphonenumber-js@1.10.14:
resolution: {integrity: sha512-McGS7GV/WjJ2KjfOGhJU1oJn29RYeo7Q+RpANRbUNMQ9gj5XArpbjurSuyYPTejFwbaUojstQ4XyWCrAzGOUXw==}
dev: true
/libqp@1.1.0:
resolution: {integrity: sha512-4Rgfa0hZpG++t1Vi2IiqXG9Ad1ig4QTmtuZF946QJP4bPqOYC78ixUXgz5TW/wE7lNaNKlplSYTxQ+fR2KZ0EA==}
@ -25731,6 +25772,7 @@ packages:
/nodemailer@6.9.7:
resolution: {integrity: sha512-rUtR77ksqex/eZRLmQ21LKVH5nAAsVicAtAYudK7JgwenEDZ0UIQ1adUGqErz7sMkWYxWTTU1aeP2Jga6WQyJw==}
engines: {node: '>=6.0.0'}
dev: true
/noms@0.0.0:
resolution: {integrity: sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==}
@ -27066,6 +27108,7 @@ packages:
resolution: {integrity: sha512-bQ/0XPZZ7eX+cdAkd61uYWpfMhakH3NeteUF1R8GNa+LMqX8QFAkbCLqq+AYAns1/ueACBu/BMWhrlKGrdvGZg==}
dependencies:
crypto-js: 4.2.0
dev: true
/pkg-dir@3.0.0:
resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==}
@ -27987,6 +28030,7 @@ packages:
/psl@1.8.0:
resolution: {integrity: sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==}
dev: true
/public-encrypt@4.0.3:
resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==}
@ -28068,6 +28112,7 @@ packages:
/querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
dev: true
/queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@ -29006,6 +29051,7 @@ packages:
/requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
dev: true
/resolve-alpn@1.2.1:
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
@ -29406,6 +29452,7 @@ packages:
/scmp@2.1.0:
resolution: {integrity: sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==}
dev: true
/scoped-regex@2.1.0:
resolution: {integrity: sha512-g3WxHrqSWCZHGHlSrF51VXFdjImhwvH8ZO/pryFH56Qi0cDsZfylQa/t0jCzVQFNbNvM00HfHjkDPEuarKDSWQ==}
@ -30607,6 +30654,7 @@ packages:
transitivePeerDependencies:
- encoding
- supports-color
dev: true
/supertokens-web-js@0.8.0:
resolution: {integrity: sha512-kkvuPbdy1I0e7nejVJAhpPJhR5h7EUHdn7Fh1YqfSjZfSJh46J3JU2qWGCgSDVZuD1hSuUKb6skF9CQnltHWrQ==}
@ -31475,6 +31523,7 @@ packages:
transitivePeerDependencies:
- debug
- supports-color
dev: true
/twoslash-protocol@0.2.4:
resolution: {integrity: sha512-AEGTJj4mFGfvQc/M6qi0+s82Zq+mxLcjWZU+EUHGG8LQElyHDs+uDR+/3+m1l+WP7WL+QmWrVzFXgFX+hBg+bg==}
@ -32010,6 +32059,7 @@ packages:
dependencies:
querystringify: 2.2.0
requires-port: 1.0.0
dev: true
/url@0.10.3:
resolution: {integrity: sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==}
@ -32924,6 +32974,7 @@ packages:
/xmlbuilder@13.0.2:
resolution: {integrity: sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==}
engines: {node: '>=6.0'}
dev: true
/xmlbuilder@9.0.7:
resolution: {integrity: sha512-7YXTQc3P2l9+0rjaUbLwMKRhtmwg1M1eDf6nag7urC7pIPYLD9W/jmzQ4ptRSUbodw5S0jfoGTflLemQibSpeQ==}