mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
feat: support new cdn tokens (#1061)
This commit is contained in:
parent
e9de7a534f
commit
c8d6aa4a27
23 changed files with 1635 additions and 257 deletions
|
|
@ -13,11 +13,13 @@
|
|||
"@app/gql": "link:./testkit/gql",
|
||||
"@aws-sdk/client-s3": "3.259.0",
|
||||
"@esm2cjs/execa": "6.1.1-cjs.1",
|
||||
"@graphql-hive/client": "workspace:*",
|
||||
"@graphql-hive/core": "0.2.3",
|
||||
"@graphql-typed-document-node/core": "3.1.1",
|
||||
"@trpc/client": "10.9.0",
|
||||
"@trpc/server": "10.9.0",
|
||||
"@whatwg-node/fetch": "0.6.2",
|
||||
"bcryptjs": "2.4.3",
|
||||
"date-fns": "2.29.3",
|
||||
"dockerode": "3.3.4",
|
||||
"dotenv": "16.0.3",
|
||||
|
|
|
|||
|
|
@ -879,16 +879,21 @@ export function schemaSyncCDN(input: SchemaSyncCdnInput, token: string) {
|
|||
export function createCdnAccess(selector: TargetSelectorInput, token: string) {
|
||||
return execute({
|
||||
document: gql(/* GraphQL */ `
|
||||
mutation createCdnToken($selector: TargetSelectorInput!) {
|
||||
createCdnToken(selector: $selector) {
|
||||
url
|
||||
token
|
||||
mutation createCdnAccessToken($input: CreateCdnAccessTokenInput!) {
|
||||
createCdnAccessToken(input: $input) {
|
||||
ok {
|
||||
secretAccessToken
|
||||
cdnUrl
|
||||
}
|
||||
error {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
token,
|
||||
variables: {
|
||||
selector,
|
||||
input: { selector, alias: 'CDN Access Token' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -898,11 +903,11 @@ export async function fetchSchemaFromCDN(selector: TargetSelectorInput, token: s
|
|||
r.expectNoGraphQLErrors(),
|
||||
);
|
||||
|
||||
const cdn = cdnAccessResult.createCdnToken;
|
||||
const cdn = cdnAccessResult.createCdnAccessToken.ok!;
|
||||
|
||||
const res = await fetch(cdn.url + '/sdl', {
|
||||
const res = await fetch(cdn.cdnUrl + '/sdl', {
|
||||
headers: {
|
||||
'X-Hive-CDN-Key': cdn.token,
|
||||
'X-Hive-CDN-Key': cdn.secretAccessToken,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -917,11 +922,11 @@ export async function fetchSupergraphFromCDN(selector: TargetSelectorInput, toke
|
|||
r.expectNoGraphQLErrors(),
|
||||
);
|
||||
|
||||
const cdn = cdnAccessResult.createCdnToken;
|
||||
const cdn = cdnAccessResult.createCdnAccessToken.ok!;
|
||||
|
||||
const res = await fetch(cdn.url + '/supergraph', {
|
||||
const res = await fetch(cdn.cdnUrl + '/supergraph', {
|
||||
headers: {
|
||||
'X-Hive-CDN-Key': cdn.token,
|
||||
'X-Hive-CDN-Key': cdn.secretAccessToken,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -938,11 +943,11 @@ export async function fetchMetadataFromCDN(selector: TargetSelectorInput, token:
|
|||
r.expectNoGraphQLErrors(),
|
||||
);
|
||||
|
||||
const cdn = cdnAccessResult.createCdnToken;
|
||||
const cdn = cdnAccessResult.createCdnAccessToken.ok!;
|
||||
|
||||
const res = await fetch(cdn.url + '/metadata', {
|
||||
const res = await fetch(cdn.cdnUrl + '/metadata', {
|
||||
headers: {
|
||||
'X-Hive-CDN-Key': cdn.token,
|
||||
'X-Hive-CDN-Key': cdn.secretAccessToken,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -304,7 +304,9 @@ export function initSeed() {
|
|||
secret,
|
||||
).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
return result.createCdnToken;
|
||||
expect(result.createCdnAccessToken.ok).not.toBeNull();
|
||||
|
||||
return result.createCdnAccessToken.ok!;
|
||||
},
|
||||
async publishSchema(options: {
|
||||
sdl: string;
|
||||
|
|
@ -376,7 +378,7 @@ export function initSeed() {
|
|||
return result.schemaVersions.nodes;
|
||||
},
|
||||
async fetchTokenInfo() {
|
||||
const tokenInfoResult = await readTokenInfo(secret!).then(r =>
|
||||
const tokenInfoResult = await readTokenInfo(secret).then(r =>
|
||||
r.expectNoGraphQLErrors(),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import crypto from 'crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { ApolloGateway } from '@apollo/gateway';
|
||||
import { ApolloServer } from '@apollo/server';
|
||||
|
|
@ -7,10 +8,13 @@ import {
|
|||
DeleteObjectsCommand,
|
||||
GetObjectCommand,
|
||||
ListObjectsCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { createSupergraphManager } from '@graphql-hive/client';
|
||||
import { fetch } from '@whatwg-node/fetch';
|
||||
import { gql } from '../../testkit/gql';
|
||||
import { execute } from '../../testkit/graphql';
|
||||
import { initSeed } from '../../testkit/seed';
|
||||
import { getServiceHost } from '../../testkit/utils';
|
||||
|
||||
|
|
@ -53,6 +57,15 @@ async function deleteAllS3BucketObjects(s3Client: S3Client, bucketName: string)
|
|||
await deleteS3Object(s3Client, bucketName, keysToDelete);
|
||||
}
|
||||
|
||||
async function putS3Object(s3Client: S3Client, bucketName: string, key: string, body: string) {
|
||||
const putObjectCommand = new PutObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
Body: body,
|
||||
});
|
||||
await s3Client.send(putObjectCommand);
|
||||
}
|
||||
|
||||
async function fetchS3ObjectArtifact(
|
||||
bucketName: string,
|
||||
key: string,
|
||||
|
|
@ -80,6 +93,17 @@ function buildEndpointUrl(
|
|||
return `${baseUrl}${targetId}/${resourceType}`;
|
||||
}
|
||||
|
||||
function generateLegacyToken(targetId: string) {
|
||||
const encoder = new TextEncoder();
|
||||
return (
|
||||
crypto
|
||||
// eslint-disable-next-line no-process-env
|
||||
.createHmac('sha256', process.env.HIVE_ENCRYPTION_SECRET!)
|
||||
.update(encoder.encode(targetId))
|
||||
.digest('base64')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* We have both a CDN that runs as part of the server and one that runs as a standalone service (cloudflare worker).
|
||||
*/
|
||||
|
|
@ -91,6 +115,97 @@ function runArtifactsCDNTests(
|
|||
getServiceHost(runtime.service, runtime.port).then(v => `http://${v}${runtime.path}`);
|
||||
|
||||
describe(`Artifacts CDN ${name}`, () => {
|
||||
test.concurrent('legacy cdn access key can be used for accessing artifacts', async () => {
|
||||
const endpointBaseUrl = await getBaseEndpoint();
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject } = await createOrg();
|
||||
const { target, createToken } = await createProject(ProjectType.Single);
|
||||
const token = await createToken({
|
||||
targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite],
|
||||
});
|
||||
|
||||
await token
|
||||
.publishSchema({
|
||||
author: 'Kamil',
|
||||
commit: 'abc123',
|
||||
sdl: `type Query { ping: String }`,
|
||||
})
|
||||
.then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
// manually generate CDN access token for legacy support
|
||||
const legacyToken = generateLegacyToken(target.id);
|
||||
const legacyTokenHash = await bcrypt.hash(legacyToken, await bcrypt.genSalt(10));
|
||||
await putS3Object(s3Client, 'artifacts', `cdn-legacy-keys/${target.id}`, legacyTokenHash);
|
||||
|
||||
const url = buildEndpointUrl(endpointBaseUrl, target.id, 'sdl');
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-hive-cdn-key': legacyToken,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(await response.text()).toMatchInlineSnapshot(`
|
||||
"type Query {
|
||||
ping: String
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
test.concurrent(
|
||||
'legacy deleting cdn access token from s3 revokes artifact cdn access',
|
||||
async () => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject } = await createOrg();
|
||||
const { createToken, target } = await createProject(ProjectType.Single);
|
||||
const writeToken = await createToken({
|
||||
targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite],
|
||||
});
|
||||
|
||||
await writeToken
|
||||
.publishSchema({
|
||||
author: 'Kamil',
|
||||
commit: 'abc123',
|
||||
sdl: `type Query { ping: String }`,
|
||||
})
|
||||
.then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
// manually generate CDN access token for legacy support
|
||||
const legacyToken = generateLegacyToken(target.id);
|
||||
const legacyTokenHash = await bcrypt.hash(legacyToken, await bcrypt.genSalt(10));
|
||||
await putS3Object(s3Client, 'artifacts', `cdn-legacy-keys/${target.id}`, legacyTokenHash);
|
||||
|
||||
const endpointBaseUrl = await getBaseEndpoint();
|
||||
|
||||
// First roundtrip
|
||||
const url = buildEndpointUrl(endpointBaseUrl, target.id, 'sdl');
|
||||
let response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-hive-cdn-key': legacyToken,
|
||||
},
|
||||
});
|
||||
expect(response.status).toEqual(200);
|
||||
expect(await response.text()).toMatchInlineSnapshot(`
|
||||
"type Query {
|
||||
ping: String
|
||||
}"
|
||||
`);
|
||||
|
||||
await deleteS3Object(s3Client, 'artifacts', [`cdn-legacy-keys/${target.id}`]);
|
||||
|
||||
// Second roundtrip
|
||||
response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-hive-cdn-key': legacyToken,
|
||||
},
|
||||
});
|
||||
expect(response.status).toEqual(403);
|
||||
},
|
||||
);
|
||||
|
||||
test.concurrent('access without credentials', async () => {
|
||||
const endpointBaseUrl = await getBaseEndpoint();
|
||||
const url = buildEndpointUrl(endpointBaseUrl, 'i-do-not-exist', 'sdl');
|
||||
|
|
@ -127,87 +242,6 @@ function runArtifactsCDNTests(
|
|||
expect(response.headers.get('location')).toEqual(null);
|
||||
});
|
||||
|
||||
test.concurrent('created (legacy) cdn access key is stored on S3', async () => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject } = await createOrg();
|
||||
const { createToken, target } = await createProject(ProjectType.Single);
|
||||
const writeToken = await createToken({
|
||||
targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite],
|
||||
});
|
||||
const cdnAccess = await writeToken.createCdnAccess();
|
||||
const result = await fetchS3ObjectArtifact('artifacts', `cdn-legacy-keys/${target.id}`);
|
||||
const isMatch = await bcrypt.compare(cdnAccess.token, result.body);
|
||||
expect(isMatch).toEqual(true);
|
||||
});
|
||||
|
||||
test.concurrent('creating (legacy) cdn access token can be done multiple times', async () => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject } = await createOrg();
|
||||
const { createToken, target } = await createProject(ProjectType.Single);
|
||||
const writeToken = await createToken({
|
||||
targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite],
|
||||
});
|
||||
|
||||
let cdnAccess = await writeToken.createCdnAccess();
|
||||
const firstResult = await fetchS3ObjectArtifact('artifacts', `cdn-legacy-keys/${target.id}`);
|
||||
let isMatch = await bcrypt.compare(cdnAccess.token, firstResult.body);
|
||||
expect(isMatch).toEqual(true);
|
||||
|
||||
cdnAccess = await writeToken.createCdnAccess();
|
||||
const secondResult = await fetchS3ObjectArtifact('artifacts', `cdn-legacy-keys/${target.id}`);
|
||||
isMatch = await bcrypt.compare(cdnAccess.token, secondResult.body);
|
||||
expect(isMatch).toEqual(true);
|
||||
});
|
||||
|
||||
test.concurrent(
|
||||
'deleting (legacy) cdn access token from s3 revokes artifact cdn access',
|
||||
async () => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject } = await createOrg();
|
||||
const { createToken, target } = await createProject(ProjectType.Single);
|
||||
const writeToken = await createToken({
|
||||
targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite],
|
||||
});
|
||||
|
||||
await writeToken
|
||||
.publishSchema({
|
||||
author: 'Kamil',
|
||||
commit: 'abc123',
|
||||
sdl: `type Query { ping: String }`,
|
||||
})
|
||||
.then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
const cdnAccess = await writeToken.createCdnAccess();
|
||||
const endpointBaseUrl = await getBaseEndpoint();
|
||||
|
||||
// First roundtrip
|
||||
const url = buildEndpointUrl(endpointBaseUrl, target!.id, 'sdl');
|
||||
let response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-hive-cdn-key': cdnAccess.token,
|
||||
},
|
||||
});
|
||||
expect(response.status).toEqual(200);
|
||||
expect(await response.text()).toMatchInlineSnapshot(`
|
||||
"type Query {
|
||||
ping: String
|
||||
}"
|
||||
`);
|
||||
|
||||
await deleteS3Object(s3Client, 'artifacts', [`cdn-legacy-keys/${target.id}`]);
|
||||
|
||||
// Second roundtrip
|
||||
response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-hive-cdn-key': cdnAccess.token,
|
||||
},
|
||||
});
|
||||
expect(response.status).toEqual(403);
|
||||
},
|
||||
);
|
||||
|
||||
test.concurrent('access SDL artifact with valid credentials', async () => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject } = await createOrg();
|
||||
|
|
@ -228,11 +262,11 @@ function runArtifactsCDNTests(
|
|||
expect(publishSchemaResult.schemaPublish.__typename).toEqual('SchemaPublishSuccess');
|
||||
const cdnAccessResult = await writeToken.createCdnAccess();
|
||||
const endpointBaseUrl = await getBaseEndpoint();
|
||||
const url = buildEndpointUrl(endpointBaseUrl, target!.id, 'sdl');
|
||||
const url = buildEndpointUrl(endpointBaseUrl, target.id, 'sdl');
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-hive-cdn-key': cdnAccessResult.token,
|
||||
'x-hive-cdn-key': cdnAccessResult.secretAccessToken,
|
||||
},
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
|
@ -243,7 +277,7 @@ function runArtifactsCDNTests(
|
|||
|
||||
const artifactContents = await fetchS3ObjectArtifact(
|
||||
'artifacts',
|
||||
`artifact/${target!.id}/sdl`,
|
||||
`artifact/${target.id}/sdl`,
|
||||
);
|
||||
expect(artifactContents.body).toMatchInlineSnapshot(`
|
||||
"type Query {
|
||||
|
|
@ -276,7 +310,7 @@ function runArtifactsCDNTests(
|
|||
// check if artifact exists in bucket
|
||||
const artifactContents = await fetchS3ObjectArtifact(
|
||||
'artifacts',
|
||||
`artifact/${target!.id}/services`,
|
||||
`artifact/${target.id}/services`,
|
||||
);
|
||||
expect(artifactContents.body).toMatchInlineSnapshot(
|
||||
`"[{"name":"ping","sdl":"type Query { ping: String }","url":"ping.com"}]"`,
|
||||
|
|
@ -284,11 +318,11 @@ function runArtifactsCDNTests(
|
|||
|
||||
const cdnAccessResult = await writeToken.createCdnAccess();
|
||||
const endpointBaseUrl = await getBaseEndpoint();
|
||||
const url = buildEndpointUrl(endpointBaseUrl, target!.id, 'services');
|
||||
const url = buildEndpointUrl(endpointBaseUrl, target.id, 'services');
|
||||
let response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-hive-cdn-key': cdnAccessResult.token,
|
||||
'x-hive-cdn-key': cdnAccessResult.secretAccessToken,
|
||||
},
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
|
@ -338,7 +372,7 @@ function runArtifactsCDNTests(
|
|||
// check if artifact exists in bucket
|
||||
const artifactContents = await fetchS3ObjectArtifact(
|
||||
'artifacts',
|
||||
`artifact/${target!.id}/services`,
|
||||
`artifact/${target.id}/services`,
|
||||
);
|
||||
expect(artifactContents.body).toMatchInlineSnapshot(
|
||||
`"[{"name":"ping","sdl":"type Query { ping: String }","url":"ping.com"}]"`,
|
||||
|
|
@ -346,11 +380,11 @@ function runArtifactsCDNTests(
|
|||
|
||||
const cdnAccessResult = await writeToken.createCdnAccess();
|
||||
const endpointBaseUrl = await getBaseEndpoint();
|
||||
const url = buildEndpointUrl(endpointBaseUrl, target!.id, 'services');
|
||||
const url = buildEndpointUrl(endpointBaseUrl, target.id, 'services');
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-hive-cdn-key': cdnAccessResult.token,
|
||||
'x-hive-cdn-key': cdnAccessResult.secretAccessToken,
|
||||
'if-none-match': artifactContents.eTag,
|
||||
},
|
||||
redirect: 'manual',
|
||||
|
|
@ -385,7 +419,7 @@ function runArtifactsCDNTests(
|
|||
const gateway = new ApolloGateway({
|
||||
supergraphSdl: createSupergraphManager({
|
||||
endpoint: endpointBaseUrl + target.id,
|
||||
key: cdnAccessResult.token,
|
||||
key: cdnAccessResult.secretAccessToken,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -432,3 +466,265 @@ function runArtifactsCDNTests(
|
|||
|
||||
runArtifactsCDNTests('API Mirror', { service: 'server', port: 8082, path: '/artifacts/v1/' });
|
||||
// runArtifactsCDNTests('Local CDN Mock', 'http://127.0.0.1:3004/artifacts/v1/');
|
||||
|
||||
describe('CDN token', () => {
|
||||
const TargetCDNAccessTokensQuery = gql(/* GraphQL */ `
|
||||
query TargetCDNAccessTokens($selector: TargetSelectorInput!, $after: String, $first: Int = 2) {
|
||||
target(selector: $selector) {
|
||||
cdnAccessTokens(first: $first, after: $after) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
firstCharacters
|
||||
lastCharacters
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const DeleteCDNAccessTokenMutation = gql(/* GraphQL */ `
|
||||
mutation DeleteCDNAccessToken($input: DeleteCdnAccessTokenInput!) {
|
||||
deleteCdnAccessToken(input: $input) {
|
||||
error {
|
||||
message
|
||||
}
|
||||
ok {
|
||||
deletedCdnAccessTokenId
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
it('connection pagination', async () => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { organization, createProject } = await createOrg();
|
||||
const { project, target, createToken } = await createProject(ProjectType.Federation);
|
||||
|
||||
const token = await createToken({
|
||||
targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.Settings],
|
||||
});
|
||||
|
||||
await Promise.all(new Array(5).fill(0).map(() => token.createCdnAccess()));
|
||||
|
||||
let result = await execute({
|
||||
document: TargetCDNAccessTokensQuery,
|
||||
variables: {
|
||||
selector: {
|
||||
organization: organization.cleanId,
|
||||
project: project.cleanId,
|
||||
target: target.cleanId,
|
||||
},
|
||||
},
|
||||
authToken: token.secret,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(result.target!.cdnAccessTokens.edges).toHaveLength(2);
|
||||
expect(result.target!.cdnAccessTokens.pageInfo.hasNextPage).toEqual(true);
|
||||
let endCursor = result.target!.cdnAccessTokens.pageInfo.endCursor;
|
||||
|
||||
result = await execute({
|
||||
document: TargetCDNAccessTokensQuery,
|
||||
variables: {
|
||||
selector: {
|
||||
organization: organization.cleanId,
|
||||
project: project.cleanId,
|
||||
target: target.cleanId,
|
||||
},
|
||||
after: endCursor,
|
||||
},
|
||||
authToken: token.secret,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(result.target!.cdnAccessTokens.edges).toHaveLength(2);
|
||||
expect(result.target!.cdnAccessTokens.pageInfo.hasNextPage).toEqual(true);
|
||||
endCursor = result.target!.cdnAccessTokens.pageInfo.endCursor;
|
||||
|
||||
result = await execute({
|
||||
document: TargetCDNAccessTokensQuery,
|
||||
variables: {
|
||||
selector: {
|
||||
organization: organization.cleanId,
|
||||
project: project.cleanId,
|
||||
target: target.cleanId,
|
||||
},
|
||||
after: endCursor,
|
||||
},
|
||||
authToken: token.secret,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(result.target!.cdnAccessTokens.edges).toHaveLength(1);
|
||||
expect(result.target!.cdnAccessTokens.pageInfo.hasNextPage).toEqual(false);
|
||||
});
|
||||
|
||||
it('new created access tokens are added at the beginning of the connection', async () => {
|
||||
const { createOrg, ownerToken } = await initSeed().createOwner();
|
||||
const { organization, createProject } = await createOrg();
|
||||
const { project, target, createToken } = await createProject(ProjectType.Federation);
|
||||
|
||||
const token = await createToken({
|
||||
targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.Settings],
|
||||
});
|
||||
|
||||
await token.createCdnAccess();
|
||||
|
||||
const firstResult = await execute({
|
||||
document: TargetCDNAccessTokensQuery,
|
||||
variables: {
|
||||
selector: {
|
||||
organization: organization.cleanId,
|
||||
project: project.cleanId,
|
||||
target: target.cleanId,
|
||||
},
|
||||
first: 2,
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
const firstId = firstResult.target!.cdnAccessTokens.edges[0].node.id;
|
||||
expect(firstResult.target!.cdnAccessTokens.edges).toHaveLength(1);
|
||||
|
||||
await token.createCdnAccess();
|
||||
|
||||
const secondResult = await execute({
|
||||
document: TargetCDNAccessTokensQuery,
|
||||
variables: {
|
||||
selector: {
|
||||
organization: organization.cleanId,
|
||||
project: project.cleanId,
|
||||
target: target.cleanId,
|
||||
},
|
||||
first: 2,
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
expect(secondResult.target!.cdnAccessTokens.edges).toHaveLength(2);
|
||||
expect(secondResult.target!.cdnAccessTokens.edges[1].node.id).toEqual(firstId);
|
||||
});
|
||||
|
||||
it('delete cdn access token', async () => {
|
||||
const { createOrg, ownerToken } = await initSeed().createOwner();
|
||||
const { organization, createProject } = await createOrg();
|
||||
const { project, target, createToken } = await createProject(ProjectType.Federation);
|
||||
|
||||
const token = await createToken({
|
||||
targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.Settings],
|
||||
});
|
||||
|
||||
await token.createCdnAccess();
|
||||
|
||||
let paginatedResult = await execute({
|
||||
document: TargetCDNAccessTokensQuery,
|
||||
variables: {
|
||||
selector: {
|
||||
organization: organization.cleanId,
|
||||
project: project.cleanId,
|
||||
target: target.cleanId,
|
||||
},
|
||||
first: 1,
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
expect(paginatedResult.target!.cdnAccessTokens.edges).toHaveLength(1);
|
||||
|
||||
const deleteResult = await execute({
|
||||
document: DeleteCDNAccessTokenMutation,
|
||||
variables: {
|
||||
input: {
|
||||
selector: {
|
||||
organization: organization.cleanId,
|
||||
project: project.cleanId,
|
||||
target: target.cleanId,
|
||||
},
|
||||
cdnAccessTokenId: paginatedResult.target!.cdnAccessTokens.edges[0].node.id,
|
||||
},
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(deleteResult.deleteCdnAccessToken.ok).toBeDefined();
|
||||
expect(deleteResult.deleteCdnAccessToken.error).toBeNull();
|
||||
expect(deleteResult.deleteCdnAccessToken.ok!.deletedCdnAccessTokenId).toEqual(
|
||||
paginatedResult.target!.cdnAccessTokens.edges[0].node.id,
|
||||
);
|
||||
|
||||
paginatedResult = await execute({
|
||||
document: TargetCDNAccessTokensQuery,
|
||||
variables: {
|
||||
selector: {
|
||||
organization: organization.cleanId,
|
||||
project: project.cleanId,
|
||||
target: target.cleanId,
|
||||
},
|
||||
first: 1,
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
expect(paginatedResult.target!.cdnAccessTokens.edges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('delete cdn access token without access', async () => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject, organization } = await createOrg();
|
||||
const { target, project, createToken } = await createProject(ProjectType.Federation);
|
||||
const token = await createToken({
|
||||
targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.Settings],
|
||||
});
|
||||
|
||||
await token.createCdnAccess();
|
||||
|
||||
const paginatedResult = await execute({
|
||||
document: TargetCDNAccessTokensQuery,
|
||||
variables: {
|
||||
selector: {
|
||||
organization: organization.cleanId,
|
||||
project: project.cleanId,
|
||||
target: target.cleanId,
|
||||
},
|
||||
first: 1,
|
||||
},
|
||||
authToken: token.secret,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
expect(paginatedResult.target!.cdnAccessTokens.edges).toHaveLength(1);
|
||||
|
||||
const { ownerToken } = await initSeed().createOwner();
|
||||
const deleteResult = await execute({
|
||||
document: DeleteCDNAccessTokenMutation,
|
||||
variables: {
|
||||
input: {
|
||||
selector: {
|
||||
organization: organization.cleanId,
|
||||
project: project.cleanId,
|
||||
target: target.cleanId,
|
||||
},
|
||||
cdnAccessTokenId: paginatedResult.target!.cdnAccessTokens.edges[0].node.id,
|
||||
},
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectGraphQLErrors());
|
||||
|
||||
expect(deleteResult).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"locations": [
|
||||
{
|
||||
"column": 3,
|
||||
"line": 2,
|
||||
},
|
||||
],
|
||||
"message": "No access (reason: "Missing target:settings permission")",
|
||||
"path": [
|
||||
"deleteCdnAccessToken",
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -587,7 +587,7 @@ test.concurrent('CDN data can not be fetched with an invalid access token', asyn
|
|||
expect(result.schemaPublish.__typename).toBe('SchemaPublishSuccess');
|
||||
|
||||
const cdn = await readWriteToken.createCdnAccess();
|
||||
const res = await fetch(cdn.url + '/sdl', {
|
||||
const res = await fetch(cdn.cdnUrl + '/sdl', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Hive-CDN-Key': 'i-like-turtles',
|
||||
|
|
@ -620,12 +620,12 @@ test.concurrent('CDN data can be fetched with an valid access token', async () =
|
|||
expect(result.schemaPublish.__typename).toBe('SchemaPublishSuccess');
|
||||
|
||||
const cdn = await readWriteToken.createCdnAccess();
|
||||
const artifactUrl = cdn.url + '/sdl';
|
||||
const artifactUrl = cdn.cdnUrl + '/sdl';
|
||||
|
||||
const cdnResult = await fetch(artifactUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Hive-CDN-Key': cdn.token,
|
||||
'X-Hive-CDN-Key': cdn.secretAccessToken,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { gql } from 'graphql-modules';
|
|||
|
||||
export default gql`
|
||||
extend type Mutation {
|
||||
createCdnToken(selector: TargetSelectorInput!): CdnTokenResult!
|
||||
createCdnAccessToken(input: CreateCdnAccessTokenInput!): CdnAccessTokenCreateResult!
|
||||
deleteCdnAccessToken(input: DeleteCdnAccessTokenInput!): DeleteCdnAccessTokenResult!
|
||||
}
|
||||
|
||||
type CdnTokenResult {
|
||||
|
|
@ -16,4 +17,77 @@ export default gql`
|
|||
"""
|
||||
isCDNEnabled: Boolean!
|
||||
}
|
||||
|
||||
extend type Target {
|
||||
"""
|
||||
The URL for accessing this target's artifacts via the CDN.
|
||||
"""
|
||||
cdnUrl: String!
|
||||
"""
|
||||
A paginated connection of CDN tokens for accessing this target's artifacts.
|
||||
"""
|
||||
cdnAccessTokens(first: Int, after: String): TargetCdnAccessTokenConnection!
|
||||
}
|
||||
|
||||
type CdnAccessToken {
|
||||
id: ID!
|
||||
alias: String!
|
||||
firstCharacters: String!
|
||||
lastCharacters: String!
|
||||
createdAt: DateTime!
|
||||
}
|
||||
|
||||
type TargetCdnAccessTokenConnection {
|
||||
edges: [TargetCdnAccessTokenEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
type TargetCdnAccessTokenEdge {
|
||||
node: CdnAccessToken!
|
||||
cursor: String!
|
||||
}
|
||||
|
||||
input DeleteCdnAccessTokenInput {
|
||||
selector: TargetSelectorInput!
|
||||
cdnAccessTokenId: ID!
|
||||
}
|
||||
|
||||
"""
|
||||
@oneOf
|
||||
"""
|
||||
type DeleteCdnAccessTokenResult {
|
||||
ok: DeleteCdnAccessTokenOk
|
||||
error: DeleteCdnAccessTokenError
|
||||
}
|
||||
|
||||
type DeleteCdnAccessTokenOk {
|
||||
deletedCdnAccessTokenId: ID!
|
||||
}
|
||||
|
||||
type DeleteCdnAccessTokenError implements Error {
|
||||
message: String!
|
||||
}
|
||||
|
||||
input CreateCdnAccessTokenInput {
|
||||
selector: TargetSelectorInput!
|
||||
alias: String!
|
||||
}
|
||||
|
||||
"""
|
||||
@oneOf
|
||||
"""
|
||||
type CdnAccessTokenCreateResult {
|
||||
ok: CdnAccessTokenCreateOk
|
||||
error: CdnAccessTokenCreateError
|
||||
}
|
||||
|
||||
type CdnAccessTokenCreateOk {
|
||||
createdCdnAccessToken: CdnAccessToken!
|
||||
secretAccessToken: String!
|
||||
cdnUrl: String!
|
||||
}
|
||||
|
||||
type CdnAccessTokenCreateError implements Error {
|
||||
message: String!
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { createHmac } from 'crypto';
|
||||
import bcryptjs from 'bcryptjs';
|
||||
import { Inject, Injectable, Scope } from 'graphql-modules';
|
||||
import { z } from 'zod';
|
||||
import { encodeCdnToken, generatePrivateKey } from '@hive/cdn-script/cdn-token';
|
||||
import type { Span } from '@sentry/types';
|
||||
import { crypto } from '@whatwg-node/fetch';
|
||||
import { HiveError } from '../../../shared/errors';
|
||||
import { isUUID } from '../../../shared/is-uuid';
|
||||
import { sentry } from '../../../shared/sentry';
|
||||
import { AuthManager } from '../../auth/providers/auth-manager';
|
||||
import { TargetAccessScope } from '../../auth/providers/scopes';
|
||||
|
|
@ -15,14 +17,14 @@ import { CDN_CONFIG, type CDNConfig } from './tokens';
|
|||
|
||||
type CdnResourceType = 'schema' | 'supergraph' | 'metadata';
|
||||
|
||||
const s3KeyPrefix = 'cdn-keys';
|
||||
|
||||
@Injectable({
|
||||
scope: Scope.Operation,
|
||||
global: true,
|
||||
})
|
||||
export class CdnProvider {
|
||||
private logger: Logger;
|
||||
private encoder: TextEncoder;
|
||||
private secretKeyData: Uint8Array;
|
||||
|
||||
constructor(
|
||||
logger: Logger,
|
||||
|
|
@ -33,8 +35,6 @@ export class CdnProvider {
|
|||
@Inject(Storage) private storage: Storage,
|
||||
) {
|
||||
this.logger = logger.child({ source: 'CdnProvider' });
|
||||
this.encoder = new TextEncoder();
|
||||
this.secretKeyData = this.encoder.encode(this.config.authPrivateKey);
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
|
|
@ -52,51 +52,6 @@ export class CdnProvider {
|
|||
throw new HiveError(`CDN is not configured, cannot resolve CDN target url.`);
|
||||
}
|
||||
|
||||
async generateCdnAccess(args: { organizationId: string; projectId: string; targetId: string }) {
|
||||
await this.authManager.ensureTargetAccess({
|
||||
organization: args.organizationId,
|
||||
project: args.projectId,
|
||||
target: args.targetId,
|
||||
scope: TargetAccessScope.REGISTRY_READ,
|
||||
});
|
||||
|
||||
const token = this.legacy_generateToken(args.targetId);
|
||||
const tokenHash = await bcryptjs.hash(token, await bcryptjs.genSalt());
|
||||
const url = this.getCdnUrlForTarget(args.targetId);
|
||||
|
||||
const s3Key = `cdn-legacy-keys/${args.targetId}`;
|
||||
|
||||
const s3Url = [this.s3Config.endpoint, this.s3Config.bucket, s3Key].join('/');
|
||||
const response = await this.s3Config.client.fetch(s3Url, {
|
||||
method: 'PUT',
|
||||
body: tokenHash,
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Unexpected Status for storing key. (status=${response.status})`);
|
||||
}
|
||||
|
||||
await this.storage.createCDNAccessToken({
|
||||
id: crypto.randomUUID(),
|
||||
targetId: args.targetId,
|
||||
s3Key,
|
||||
firstCharacters: token.substring(0, 3),
|
||||
lastCharacters: token.substring(token.length - 3),
|
||||
alias: 'CDN Access Token',
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
private legacy_generateToken(targetId: string): string {
|
||||
return createHmac('sha256', this.secretKeyData)
|
||||
.update(this.encoder.encode(targetId))
|
||||
.digest('base64');
|
||||
}
|
||||
|
||||
async pushToCloudflareCDN(url: string, body: string, span?: Span): Promise<{ success: boolean }> {
|
||||
if (this.config.providers.cloudflare === null) {
|
||||
this.logger.info(`Trying to push to the CDN, but CDN is not configured, skipping`);
|
||||
|
|
@ -161,4 +116,177 @@ export class CdnProvider {
|
|||
result,
|
||||
);
|
||||
}
|
||||
|
||||
async createCDNAccessToken(args: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
targetId: string;
|
||||
alias: string;
|
||||
}) {
|
||||
const alias = AliasStringModel.safeParse(args.alias);
|
||||
|
||||
if (alias.success === false) {
|
||||
return {
|
||||
type: 'failure',
|
||||
reason: alias.error.issues[0].message,
|
||||
} as const;
|
||||
}
|
||||
|
||||
await this.authManager.ensureTargetAccess({
|
||||
organization: args.organizationId,
|
||||
project: args.projectId,
|
||||
target: args.targetId,
|
||||
scope: TargetAccessScope.READ,
|
||||
});
|
||||
|
||||
// generate all things upfront so we do net get surprised by encoding issues after writing to the destination.
|
||||
const keyId = crypto.randomUUID();
|
||||
const s3Key = `${s3KeyPrefix}/${args.targetId}/${keyId}`;
|
||||
const privateKey = generatePrivateKey();
|
||||
const privateKeyHash = await bcryptjs.hash(privateKey, await bcryptjs.genSalt());
|
||||
const cdnAccessToken = encodeCdnToken({ keyId, privateKey });
|
||||
|
||||
// Check if key already exists
|
||||
const headResponse = await this.s3Config.client.fetch(
|
||||
[this.s3Config.endpoint, this.s3Config.bucket, s3Key].join('/'),
|
||||
{
|
||||
method: 'HEAD',
|
||||
},
|
||||
);
|
||||
|
||||
if (headResponse.status !== 404) {
|
||||
return {
|
||||
type: 'failure',
|
||||
reason: 'Failed to generate key. Please try again later.',
|
||||
} as const;
|
||||
}
|
||||
|
||||
// put key onto s3 bucket
|
||||
const putResponse = await this.s3Config.client.fetch(
|
||||
[this.s3Config.endpoint, this.s3Config.bucket, s3Key].join('/'),
|
||||
{
|
||||
method: 'PUT',
|
||||
body: privateKeyHash,
|
||||
},
|
||||
);
|
||||
|
||||
if (putResponse.status !== 200) {
|
||||
return {
|
||||
type: 'failure',
|
||||
reason: 'Failed to generate key. Please try again later. 2',
|
||||
} as const;
|
||||
}
|
||||
|
||||
const cdnAccessTokenRecord = await this.storage.createCDNAccessToken({
|
||||
id: keyId,
|
||||
targetId: args.targetId,
|
||||
firstCharacters: cdnAccessToken.substring(0, 5),
|
||||
lastCharacters: cdnAccessToken.substring(cdnAccessToken.length - 5, cdnAccessToken.length),
|
||||
s3Key,
|
||||
alias: args.alias,
|
||||
});
|
||||
|
||||
if (cdnAccessTokenRecord === null) {
|
||||
return {
|
||||
type: 'failure',
|
||||
reason: 'Failed to generate key. Please try again later.',
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'success',
|
||||
cdnAccessToken: cdnAccessTokenRecord,
|
||||
secretAccessToken: cdnAccessToken,
|
||||
} as const;
|
||||
}
|
||||
|
||||
public async deleteCDNAccessToken(args: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
targetId: string;
|
||||
cdnAccessTokenId: string;
|
||||
}) {
|
||||
await this.authManager.ensureTargetAccess({
|
||||
organization: args.organizationId,
|
||||
project: args.projectId,
|
||||
target: args.targetId,
|
||||
scope: TargetAccessScope.SETTINGS,
|
||||
});
|
||||
|
||||
if (isUUID(args.cdnAccessTokenId) === false) {
|
||||
return {
|
||||
type: 'failure',
|
||||
reason: 'The CDN Access Token does not exist.',
|
||||
} as const;
|
||||
}
|
||||
|
||||
// TODO: this should probably happen within a db transaction to ensure integrity
|
||||
const record = await this.storage.getCDNAccessTokenById({
|
||||
cdnAccessTokenId: args.cdnAccessTokenId,
|
||||
});
|
||||
|
||||
if (record === null || record.targetId !== args.targetId) {
|
||||
return {
|
||||
type: 'failure',
|
||||
reason: 'The CDN Access Token does not exist.',
|
||||
} as const;
|
||||
}
|
||||
|
||||
const headResponse = await this.s3Config.client.fetch(
|
||||
[this.s3Config.endpoint, this.s3Config.bucket, record.s3Key].join('/'),
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
);
|
||||
|
||||
if (headResponse.status !== 204) {
|
||||
return {
|
||||
type: 'failure',
|
||||
reason: 'Failed deleting CDN Access Token. Please try again later.',
|
||||
} as const;
|
||||
}
|
||||
|
||||
await this.storage.deleteCDNAccessToken({
|
||||
cdnAccessTokenId: args.cdnAccessTokenId,
|
||||
});
|
||||
|
||||
if (record === null) {
|
||||
return {
|
||||
type: 'failure',
|
||||
reason: 'The CDN Access Token. Does not exist.',
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'success',
|
||||
} as const;
|
||||
}
|
||||
|
||||
public async getPaginatedCDNAccessTokensForTarget(args: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
targetId: string;
|
||||
first: number | null;
|
||||
cursor: string | null;
|
||||
}) {
|
||||
await this.authManager.ensureTargetAccess({
|
||||
organization: args.organizationId,
|
||||
project: args.projectId,
|
||||
target: args.targetId,
|
||||
scope: TargetAccessScope.SETTINGS,
|
||||
});
|
||||
|
||||
const paginatedResult = await this.storage.getPaginatedCDNAccessTokensForTarget({
|
||||
targetId: args.targetId,
|
||||
first: args.first,
|
||||
cursor: args.cursor,
|
||||
});
|
||||
|
||||
return paginatedResult;
|
||||
}
|
||||
}
|
||||
|
||||
const AliasStringModel = z
|
||||
.string()
|
||||
.min(3, 'Must be at least 3 characters long.')
|
||||
.max(100, 'Can not be longer than 100 characters.');
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { CdnProvider } from './providers/cdn.provider';
|
|||
|
||||
export const resolvers: CdnModule.Resolvers = {
|
||||
Mutation: {
|
||||
createCdnToken: async (_, { selector }, { injector }) => {
|
||||
createCdnAccessToken: async (_, { input }, { injector }) => {
|
||||
const translator = injector.get(IdTranslator);
|
||||
const cdn = injector.get(CdnProvider);
|
||||
|
||||
|
|
@ -14,16 +14,63 @@ export const resolvers: CdnModule.Resolvers = {
|
|||
}
|
||||
|
||||
const [organizationId, projectId, targetId] = await Promise.all([
|
||||
translator.translateOrganizationId(selector),
|
||||
translator.translateProjectId(selector),
|
||||
translator.translateTargetId(selector),
|
||||
translator.translateOrganizationId(input.selector),
|
||||
translator.translateProjectId(input.selector),
|
||||
translator.translateTargetId(input.selector),
|
||||
]);
|
||||
|
||||
return await cdn.generateCdnAccess({
|
||||
const result = await cdn.createCDNAccessToken({
|
||||
organizationId,
|
||||
projectId,
|
||||
targetId,
|
||||
alias: input.alias,
|
||||
});
|
||||
|
||||
if (result.type === 'failure') {
|
||||
return {
|
||||
error: {
|
||||
message: result.reason,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: {
|
||||
secretAccessToken: result.secretAccessToken,
|
||||
createdCdnAccessToken: result.cdnAccessToken,
|
||||
cdnUrl: cdn.getCdnUrlForTarget(targetId),
|
||||
},
|
||||
};
|
||||
},
|
||||
deleteCdnAccessToken: async (_, { input }, { injector }) => {
|
||||
const translator = injector.get(IdTranslator);
|
||||
|
||||
const [organizationId, projectId, targetId] = await Promise.all([
|
||||
translator.translateOrganizationId(input.selector),
|
||||
translator.translateProjectId(input.selector),
|
||||
translator.translateTargetId(input.selector),
|
||||
]);
|
||||
|
||||
const deleteResult = await injector.get(CdnProvider).deleteCDNAccessToken({
|
||||
organizationId,
|
||||
projectId,
|
||||
targetId,
|
||||
cdnAccessTokenId: input.cdnAccessTokenId,
|
||||
});
|
||||
|
||||
if (deleteResult.type === 'failure') {
|
||||
return {
|
||||
error: {
|
||||
message: deleteResult.reason,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: {
|
||||
deletedCdnAccessTokenId: input.cdnAccessTokenId,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
Query: {
|
||||
|
|
@ -33,4 +80,23 @@ export const resolvers: CdnModule.Resolvers = {
|
|||
return cdn.isEnabled();
|
||||
},
|
||||
},
|
||||
Target: {
|
||||
async cdnAccessTokens(target, args, context) {
|
||||
const result = await context.injector.get(CdnProvider).getPaginatedCDNAccessTokensForTarget({
|
||||
targetId: target.id,
|
||||
projectId: target.projectId,
|
||||
organizationId: target.orgId,
|
||||
first: args.first ?? null,
|
||||
cursor: args.after ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
edges: result.items,
|
||||
pageInfo: result.pageInfo,
|
||||
};
|
||||
},
|
||||
cdnUrl(target, _args, context) {
|
||||
return context.injector.get(CdnProvider).getCdnUrlForTarget(target.id);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -465,6 +465,29 @@ export interface Storage {
|
|||
lastCharacters: string;
|
||||
alias: string;
|
||||
}): Promise<CDNAccessToken | null>;
|
||||
|
||||
getCDNAccessTokenById(_: { cdnAccessTokenId: string }): Promise<CDNAccessToken | null>;
|
||||
|
||||
deleteCDNAccessToken(_: { cdnAccessTokenId: string }): Promise<boolean>;
|
||||
|
||||
getPaginatedCDNAccessTokensForTarget(_: {
|
||||
targetId: string;
|
||||
first: number | null;
|
||||
cursor: null | string;
|
||||
}): Promise<
|
||||
Readonly<{
|
||||
items: ReadonlyArray<{
|
||||
node: CDNAccessToken;
|
||||
cursor: string;
|
||||
}>;
|
||||
pageInfo: Readonly<{
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
startCursor: string;
|
||||
endCursor: string;
|
||||
}>;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
|
|
|||
6
packages/services/api/src/shared/is-uuid.ts
Normal file
6
packages/services/api/src/shared/is-uuid.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Utility for checking whether a string value is a valid UUID that can be forwarded to Postgres.
|
||||
*/
|
||||
export function isUUID(value: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["src"]
|
||||
"include": ["src", "../cdn-worker/src/cdn-token.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"name": "@hive/cdn-script",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -19,25 +19,30 @@ type Event =
|
|||
| 'sdl.graphqls';
|
||||
}
|
||||
| {
|
||||
type: 'new-key-validation';
|
||||
type: 'key-validation';
|
||||
value:
|
||||
| {
|
||||
type: 'cache-hit';
|
||||
version: 'v1' | 'legacy';
|
||||
isValid: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'cache-write';
|
||||
version: 'v1' | 'legacy';
|
||||
isValid: boolean;
|
||||
}
|
||||
| {
|
||||
type: 's3-key-read-failure';
|
||||
version: 'v1' | 'legacy';
|
||||
status: number | null;
|
||||
}
|
||||
| {
|
||||
type: 's3-key-compare-failure';
|
||||
version: 'v1' | 'legacy';
|
||||
}
|
||||
| {
|
||||
type: 's3-key-validation-success';
|
||||
type: 's3-key-validation';
|
||||
version: 'v1' | 'legacy';
|
||||
status: 'success' | 'failure';
|
||||
};
|
||||
}
|
||||
|
|
@ -69,21 +74,29 @@ export function createAnalytics(
|
|||
return engines.error.writeDataPoint({
|
||||
blobs: event.value,
|
||||
});
|
||||
case 'new-key-validation':
|
||||
case 'key-validation':
|
||||
switch (event.value.type) {
|
||||
case 'cache-hit':
|
||||
return engines.keyValidation.writeDataPoint({
|
||||
blobs: ['cache-hit', event.value.isValid ? 'valid' : 'invalid'],
|
||||
blobs: [
|
||||
'cache-hit',
|
||||
event.value.version,
|
||||
event.value.isValid ? 'valid' : 'invalid',
|
||||
],
|
||||
indexes: [targetId.substr(0, 32)],
|
||||
});
|
||||
case 'cache-write':
|
||||
return engines.keyValidation.writeDataPoint({
|
||||
blobs: ['cache-write', event.value.isValid ? 'valid' : 'invalid'],
|
||||
blobs: [
|
||||
'cache-write',
|
||||
event.value.version,
|
||||
event.value.isValid ? 'valid' : 'invalid',
|
||||
],
|
||||
indexes: [targetId.substr(0, 32)],
|
||||
});
|
||||
case 's3-key-validation-success':
|
||||
case 's3-key-validation':
|
||||
return engines.keyValidation.writeDataPoint({
|
||||
blobs: ['s3-key-validation-success', event.value.status],
|
||||
blobs: ['s3-key-validation', event.value.version, event.value.status],
|
||||
indexes: [targetId.substr(0, 32)],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
64
packages/services/cdn-worker/src/cdn-token.ts
Normal file
64
packages/services/cdn-worker/src/cdn-token.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Note: This file needs to run both on Node.js and Cloudflare Workers.
|
||||
*/
|
||||
import * as bc from 'bcryptjs';
|
||||
import { crypto } from '@whatwg-node/fetch';
|
||||
|
||||
export interface CDNToken {
|
||||
keyId: string;
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* We prefix the token so we can check fast wether a token is a new one or a legacy one.
|
||||
*/
|
||||
const keyPrefix = 'hv2';
|
||||
|
||||
/**
|
||||
* Encode a CDN token into a hex string with prefix.
|
||||
*/
|
||||
export function encodeCdnToken(args: CDNToken): string {
|
||||
const keyContents = [args.keyId, args.privateKey].join(':');
|
||||
return keyPrefix + globalThis.btoa(keyContents);
|
||||
}
|
||||
|
||||
export function isCDNAccessToken(token: string) {
|
||||
return token.startsWith(keyPrefix) === true;
|
||||
}
|
||||
|
||||
const decodeError = { type: 'failure', reason: 'Invalid access token.' } as const;
|
||||
|
||||
/**
|
||||
* Safely decode a CDN token that got serialized as a string.
|
||||
*/
|
||||
export function decodeCdnAccessTokenSafe(token: string) {
|
||||
if (isCDNAccessToken(token) === false) {
|
||||
return decodeError;
|
||||
}
|
||||
|
||||
token = token.slice(keyPrefix.length);
|
||||
|
||||
const str = globalThis.atob(token);
|
||||
const [keyId, privateKey] = str.split(':');
|
||||
if (keyId && privateKey) {
|
||||
return { type: 'success', token: { keyId, privateKey } } as const;
|
||||
}
|
||||
return decodeError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify whether a CDN token is valid.
|
||||
*/
|
||||
export async function verifyCdnToken(privateKey: string, privateKeyHash: string) {
|
||||
return bc.compare(privateKey, privateKeyHash);
|
||||
}
|
||||
|
||||
export function generatePrivateKey(): string {
|
||||
const array = new Uint32Array(5);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array, dec2hex).join('');
|
||||
}
|
||||
|
||||
function dec2hex(dec: number) {
|
||||
return dec.toString(16).padStart(2, '0');
|
||||
}
|
||||
|
|
@ -1,16 +1,8 @@
|
|||
import bcrypt from 'bcryptjs';
|
||||
import { Request, Response } from '@whatwg-node/fetch';
|
||||
import { Analytics } from './analytics';
|
||||
import { AwsClient } from './aws';
|
||||
|
||||
export function byteStringToUint8Array(byteString: string) {
|
||||
const ui = new Uint8Array(byteString.length);
|
||||
|
||||
for (let i = 0; i < byteString.length; ++i) {
|
||||
ui[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return ui;
|
||||
}
|
||||
import { type AwsClient } from './aws';
|
||||
import { decodeCdnAccessTokenSafe, isCDNAccessToken } from './cdn-token';
|
||||
|
||||
export type KeyValidator = (targetId: string, headerKey: string) => Promise<boolean>;
|
||||
|
||||
|
|
@ -34,14 +26,18 @@ type CreateKeyValidatorDeps = {
|
|||
export const createIsKeyValid =
|
||||
(deps: CreateKeyValidatorDeps): KeyValidator =>
|
||||
async (targetId: string, accessHeaderValue: string): Promise<boolean> => {
|
||||
return validateKey({
|
||||
if (isCDNAccessToken(accessHeaderValue)) {
|
||||
return handleCDNAccessToken(deps, targetId, accessHeaderValue);
|
||||
}
|
||||
|
||||
return handleLegacyCDNAccessToken({
|
||||
...deps,
|
||||
targetId,
|
||||
accessToken: accessHeaderValue,
|
||||
});
|
||||
};
|
||||
|
||||
const validateKey = async (args: {
|
||||
const handleLegacyCDNAccessToken = async (args: {
|
||||
targetId: string;
|
||||
accessToken: string;
|
||||
s3: S3Config;
|
||||
|
|
@ -76,9 +72,10 @@ const validateKey = async (args: {
|
|||
|
||||
args.analytics?.track(
|
||||
{
|
||||
type: 'new-key-validation',
|
||||
type: 'key-validation',
|
||||
value: {
|
||||
type: 'cache-hit',
|
||||
version: 'legacy',
|
||||
isValid,
|
||||
},
|
||||
},
|
||||
|
|
@ -91,9 +88,10 @@ const validateKey = async (args: {
|
|||
withCache = async (isValid: boolean) => {
|
||||
args.analytics?.track(
|
||||
{
|
||||
type: 'new-key-validation',
|
||||
type: 'key-validation',
|
||||
value: {
|
||||
type: 'cache-write',
|
||||
version: 'legacy',
|
||||
isValid,
|
||||
},
|
||||
},
|
||||
|
|
@ -136,9 +134,10 @@ const validateKey = async (args: {
|
|||
|
||||
args.analytics?.track(
|
||||
{
|
||||
type: 'new-key-validation',
|
||||
type: 'key-validation',
|
||||
value: {
|
||||
type: 's3-key-validation-success',
|
||||
type: 's3-key-validation',
|
||||
version: 'legacy',
|
||||
status: isValid ? 'success' : 'failure',
|
||||
},
|
||||
},
|
||||
|
|
@ -147,3 +146,115 @@ const validateKey = async (args: {
|
|||
|
||||
return withCache(isValid);
|
||||
};
|
||||
|
||||
async function handleCDNAccessToken(
|
||||
deps: CreateKeyValidatorDeps,
|
||||
targetId: string,
|
||||
accessToken: string,
|
||||
) {
|
||||
let withCache = (isValid: boolean) => Promise.resolve(isValid);
|
||||
|
||||
{
|
||||
const requestCache = await deps.getCache?.();
|
||||
|
||||
if (requestCache) {
|
||||
const cacheKey = new Request(
|
||||
['http://key-cache.graphql-hive.com', 'v1', targetId, encodeURIComponent(accessToken)].join(
|
||||
'/',
|
||||
),
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
);
|
||||
|
||||
const response = await requestCache.match(cacheKey);
|
||||
|
||||
if (response) {
|
||||
const responseValue = await response.text();
|
||||
|
||||
const isValid = responseValue === '1';
|
||||
|
||||
deps.analytics?.track(
|
||||
{
|
||||
type: 'key-validation',
|
||||
value: {
|
||||
type: 'cache-hit',
|
||||
version: 'v1',
|
||||
isValid,
|
||||
},
|
||||
},
|
||||
targetId,
|
||||
);
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
withCache = async (isValid: boolean) => {
|
||||
deps.analytics?.track(
|
||||
{
|
||||
type: 'key-validation',
|
||||
value: {
|
||||
type: 'cache-write',
|
||||
version: 'v1',
|
||||
isValid,
|
||||
},
|
||||
},
|
||||
targetId,
|
||||
);
|
||||
|
||||
const promise = requestCache.put(
|
||||
cacheKey,
|
||||
new Response(isValid ? '1' : '0', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Cache-Control': `s-maxage=${60 * 5}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (deps.waitUntil) {
|
||||
deps.waitUntil(promise);
|
||||
} else {
|
||||
await promise;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const decodeResult = decodeCdnAccessTokenSafe(accessToken);
|
||||
|
||||
if (decodeResult.type === 'failure') {
|
||||
return withCache(false);
|
||||
}
|
||||
|
||||
const s3KeyParts = ['cdn-keys', targetId, decodeResult.token.keyId];
|
||||
|
||||
const key = await deps.s3.client.fetch(
|
||||
[deps.s3.endpoint, deps.s3.bucketName, ...s3KeyParts].join('/'),
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
);
|
||||
|
||||
if (key.status !== 200) {
|
||||
return withCache(false);
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(decodeResult.token.privateKey, await key.text());
|
||||
|
||||
deps.analytics?.track(
|
||||
{
|
||||
type: 'key-validation',
|
||||
value: {
|
||||
type: 's3-key-validation',
|
||||
version: 'v1',
|
||||
status: isValid ? 'success' : 'failure',
|
||||
},
|
||||
},
|
||||
targetId,
|
||||
);
|
||||
|
||||
return withCache(isValid);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2741,7 +2741,7 @@ export async function createStorage(connection: string, maximumPoolSize: number)
|
|||
, "first_characters"
|
||||
, "last_characters"
|
||||
, "alias"
|
||||
, "created_at"
|
||||
, to_json("created_at") as "created_at"
|
||||
`);
|
||||
|
||||
if (result === null) {
|
||||
|
|
@ -2750,11 +2750,142 @@ export async function createStorage(connection: string, maximumPoolSize: number)
|
|||
|
||||
return decodeCDNAccessTokenRecord(result);
|
||||
},
|
||||
|
||||
async getCDNAccessTokenById(args) {
|
||||
const result = await pool.maybeOne(sql`
|
||||
SELECT
|
||||
"id"
|
||||
, "target_id"
|
||||
, "s3_key"
|
||||
, "first_characters"
|
||||
, "last_characters"
|
||||
, "alias"
|
||||
, to_json("created_at") as "created_at"
|
||||
FROM
|
||||
"public"."cdn_access_tokens"
|
||||
WHERE
|
||||
"id" = ${args.cdnAccessTokenId}
|
||||
`);
|
||||
|
||||
if (result == null) {
|
||||
return null;
|
||||
}
|
||||
return decodeCDNAccessTokenRecord(result);
|
||||
},
|
||||
|
||||
async deleteCDNAccessToken(args) {
|
||||
const result = await pool.maybeOne(sql`
|
||||
DELETE
|
||||
FROM
|
||||
"public"."cdn_access_tokens"
|
||||
WHERE
|
||||
"id" = ${args.cdnAccessTokenId}
|
||||
RETURNING
|
||||
"id"
|
||||
`);
|
||||
|
||||
return result != null;
|
||||
},
|
||||
|
||||
async getPaginatedCDNAccessTokensForTarget(args) {
|
||||
let cursor: null | {
|
||||
createdAt: string;
|
||||
id: string;
|
||||
} = null;
|
||||
|
||||
const limit = args.first ? (args.first > 0 ? Math.min(args.first, 20) : 20) : 20;
|
||||
|
||||
if (args.cursor) {
|
||||
cursor = decodeCDNAccessTokenCursor(args.cursor);
|
||||
}
|
||||
|
||||
const result = await pool.any(sql`
|
||||
SELECT
|
||||
"id"
|
||||
, "target_id"
|
||||
, "s3_key"
|
||||
, "first_characters"
|
||||
, "last_characters"
|
||||
, "alias"
|
||||
, to_json("created_at") as "created_at"
|
||||
FROM
|
||||
"public"."cdn_access_tokens"
|
||||
WHERE
|
||||
"target_id" = ${args.targetId}
|
||||
${
|
||||
cursor
|
||||
? sql`
|
||||
AND (
|
||||
(
|
||||
"cdn_access_tokens"."created_at" = ${cursor.createdAt}
|
||||
AND "id" < ${cursor.id}
|
||||
)
|
||||
OR "cdn_access_tokens"."created_at" < ${cursor.createdAt}
|
||||
)
|
||||
`
|
||||
: sql``
|
||||
}
|
||||
ORDER BY
|
||||
"target_id" ASC
|
||||
, "cdn_access_tokens"."created_at" DESC
|
||||
, "id" DESC
|
||||
LIMIT ${limit + 1}
|
||||
`);
|
||||
|
||||
let items = result.map(row => {
|
||||
const node = decodeCDNAccessTokenRecord(row);
|
||||
|
||||
return {
|
||||
node,
|
||||
get cursor() {
|
||||
return encodeCDNAccessTokenCursor(node);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hasNextPage = items.length > limit;
|
||||
|
||||
items = items.slice(0, limit);
|
||||
|
||||
return {
|
||||
items,
|
||||
pageInfo: {
|
||||
hasNextPage,
|
||||
hasPreviousPage: cursor !== null,
|
||||
get endCursor() {
|
||||
return items[items.length - 1]?.cursor ?? '';
|
||||
},
|
||||
get startCursor() {
|
||||
return items[0]?.cursor ?? '';
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return storage;
|
||||
}
|
||||
|
||||
function encodeCDNAccessTokenCursor(cursor: { createdAt: string; id: string }) {
|
||||
return Buffer.from(`${cursor.createdAt}|${cursor.id}`).toString('base64');
|
||||
}
|
||||
|
||||
function decodeCDNAccessTokenCursor(cursor: string) {
|
||||
const [createdAt, id] = Buffer.from(cursor, 'base64').toString('utf8').split('|');
|
||||
if (
|
||||
Number.isNaN(Date.parse(createdAt)) ||
|
||||
id === undefined ||
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id) === false
|
||||
) {
|
||||
throw new Error('Invalid cursor');
|
||||
}
|
||||
|
||||
return {
|
||||
createdAt,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
function isDefined<T>(val: T | undefined | null): val is T {
|
||||
return val !== undefined && val !== null;
|
||||
}
|
||||
|
|
@ -2818,7 +2949,7 @@ const CDNAccessTokenModel = zod.object({
|
|||
first_characters: zod.string(),
|
||||
last_characters: zod.string(),
|
||||
alias: zod.string(),
|
||||
created_at: zod.number(),
|
||||
created_at: zod.string(),
|
||||
});
|
||||
|
||||
const decodeCDNAccessTokenRecord = (result: unknown): CDNAccessToken => {
|
||||
|
|
@ -2830,8 +2961,8 @@ const decodeCDNAccessTokenRecord = (result: unknown): CDNAccessToken => {
|
|||
s3Key: rawRecord.s3_key,
|
||||
firstCharacters: rawRecord.first_characters,
|
||||
lastCharacters: rawRecord.last_characters,
|
||||
createdAt: new Date(rawRecord.created_at).toString(),
|
||||
alias: rawRecord.alias,
|
||||
createdAt: rawRecord.created_at,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import * as Yup from 'yup';
|
|||
import { authenticated } from '@/components/authenticated-container';
|
||||
import { TargetLayout } from '@/components/layouts';
|
||||
import { SchemaEditor } from '@/components/schema-editor';
|
||||
import { CDNAccessTokens } from '@/components/target/settings/cdn-access-tokens';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
|
|
@ -43,7 +44,7 @@ const columns = [
|
|||
{ key: 'createdAt', align: 'right' },
|
||||
] as const;
|
||||
|
||||
const Tokens = ({ me }: { me: MemberFieldsFragment }): ReactElement => {
|
||||
const RegistryAccessTokens = ({ me }: { me: MemberFieldsFragment }): ReactElement => {
|
||||
const router = useRouteSelector();
|
||||
const [{ fetching: deleting }, mutate] = useMutation(DeleteTokensDocument);
|
||||
const [checked, setChecked] = useState<string[]>([]);
|
||||
|
|
@ -78,7 +79,7 @@ const Tokens = ({ me }: { me: MemberFieldsFragment }): ReactElement => {
|
|||
|
||||
return (
|
||||
<Card>
|
||||
<Heading className="mb-2">Tokens</Heading>
|
||||
<Heading className="mb-2">Registry Access Tokens</Heading>
|
||||
<p className="mb-3 font-light text-gray-300">
|
||||
Be careful! These tokens allow to read and write your target data.
|
||||
</p>
|
||||
|
|
@ -660,7 +661,9 @@ const Page = ({
|
|||
)}
|
||||
</Card>
|
||||
|
||||
{canAccessTokens && <Tokens me={me} />}
|
||||
{canAccessTokens && <RegistryAccessTokens me={me} />}
|
||||
|
||||
{canAccessTokens && <CDNAccessTokens me={me} />}
|
||||
|
||||
<ConditionalBreakingChanges />
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,464 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useFormik } from 'formik';
|
||||
import { gql, useMutation, useQuery } from 'urql';
|
||||
import * as Yup from 'yup';
|
||||
import { Button, Card, Heading, Input, Modal, Table, Tag, TimeAgo } from '@/components/v2';
|
||||
import { AlertTriangleIcon, TrashIcon } from '@/components/v2/icon';
|
||||
import { InlineCode } from '@/components/v2/inline-code';
|
||||
import { TargetAccessScope } from '@/gql/graphql';
|
||||
import { MemberFieldsFragment } from '@/graphql';
|
||||
import { canAccessTarget } from '@/lib/access/target';
|
||||
import { useRouteSelector } from '@/lib/hooks';
|
||||
|
||||
// Note: this will be used in the future :)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const CDNAccessTokeRowFragment = gql(/* GraphQL */ `
|
||||
fragment CDNAccessTokens_CdnAccessTokenRowFragment on CdnAccessToken {
|
||||
id
|
||||
firstCharacters
|
||||
lastCharacters
|
||||
alias
|
||||
createdAt
|
||||
}
|
||||
`);
|
||||
|
||||
const CDNAccessTokenCreateMutation = gql(/* GraphQL */ `
|
||||
mutation CDNAccessTokens_CDNAccessTokenCreateMutation($input: CreateCdnAccessTokenInput!) {
|
||||
createCdnAccessToken(input: $input) {
|
||||
error {
|
||||
message
|
||||
}
|
||||
ok {
|
||||
createdCdnAccessToken {
|
||||
...CDNAccessTokens_CdnAccessTokenRowFragment
|
||||
}
|
||||
secretAccessToken
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const CreateCDNAccessTokenModal = (props: {
|
||||
onCreateCDNAccessToken: () => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const router = useRouteSelector();
|
||||
const [createCdnAccessToken, mutate] = useMutation(CDNAccessTokenCreateMutation);
|
||||
|
||||
const form = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
alias: '',
|
||||
},
|
||||
validationSchema: Yup.object().shape({
|
||||
alias: Yup.string().required('Please enter an alias').min(3).max(100),
|
||||
}),
|
||||
onSubmit: async values => {
|
||||
await mutate({
|
||||
input: {
|
||||
selector: {
|
||||
organization: router.organizationId,
|
||||
project: router.projectId,
|
||||
target: router.targetId,
|
||||
},
|
||||
alias: values.alias,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (createCdnAccessToken.data?.createCdnAccessToken.ok?.createdCdnAccessToken.id) {
|
||||
props.onCreateCDNAccessToken();
|
||||
}
|
||||
}, [createCdnAccessToken.data?.createCdnAccessToken.ok?.createdCdnAccessToken.id]);
|
||||
|
||||
let body = (
|
||||
<form className="flex flex-1 flex-col items-stretch gap-12" onSubmit={form.handleSubmit}>
|
||||
<div className="flex flex-col gap-5">
|
||||
<Heading className="text-center">Create CDN Access Token</Heading>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="text-sm font-semibold" htmlFor="buildUrl">
|
||||
CDN Access Token Alias
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Alias"
|
||||
name="alias"
|
||||
value={form.values.alias}
|
||||
onChange={form.handleChange}
|
||||
onBlur={form.handleBlur}
|
||||
disabled={form.isSubmitting}
|
||||
isInvalid={form.touched.alias && !!form.errors.alias}
|
||||
/>
|
||||
{form.touched.alias && form.errors.alias ? (
|
||||
<span className="text-sm text-red-500">{form.errors.alias}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex w-full gap-2 self-end">
|
||||
<Button size="large" className="ml-auto" onClick={props.onClose}>
|
||||
Abort
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="large"
|
||||
disabled={createCdnAccessToken.fetching}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
if (createCdnAccessToken.data?.createCdnAccessToken.ok) {
|
||||
body = (
|
||||
<div className="flex flex-1 flex-col items-stretch gap-12">
|
||||
<div className="flex flex-col gap-5">
|
||||
<Heading className="text-center">Create CDN Access Token</Heading>
|
||||
</div>
|
||||
|
||||
<p>The CDN Access Token was successfully created.</p>
|
||||
|
||||
<Tag color="yellow" className="py-2.5 px-4">
|
||||
<AlertTriangleIcon className="h-5 w-5" />
|
||||
Please store this access token securely. You will not be able to see it again.
|
||||
</Tag>
|
||||
|
||||
<InlineCode content={createCdnAccessToken.data.createCdnAccessToken.ok.secretAccessToken} />
|
||||
|
||||
<div className="mt-auto flex w-full gap-2 self-end">
|
||||
<Button variant="primary" size="large" className="ml-auto" onClick={props.onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (createCdnAccessToken.data?.createCdnAccessToken.error) {
|
||||
body = (
|
||||
<div className="flex flex-1 flex-col items-stretch gap-12">
|
||||
<div className="flex flex-col gap-5">
|
||||
<Heading className="text-center">Delete CDN Access Token</Heading>
|
||||
</div>
|
||||
|
||||
<p>Something went wrong.</p>
|
||||
|
||||
<Tag color="yellow" className="py-2.5 px-4">
|
||||
<AlertTriangleIcon className="h-5 w-5" />
|
||||
{createCdnAccessToken.data?.createCdnAccessToken.error.message}
|
||||
</Tag>
|
||||
|
||||
<Button variant="primary" size="large" className="ml-auto" onClick={props.onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={true} className="w-[650px]" onOpenChange={props.onClose}>
|
||||
{body}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const CDNAccessTokenDeleteMutation = gql(/* GraphQL */ `
|
||||
mutation CDNAccessTokens_DeleteCDNAccessToken($input: DeleteCdnAccessTokenInput!) {
|
||||
deleteCdnAccessToken(input: $input) {
|
||||
error {
|
||||
message
|
||||
}
|
||||
ok {
|
||||
deletedCdnAccessTokenId
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const DeleteCDNAccessTokenModal = (props: {
|
||||
cdnAccessTokenId: string;
|
||||
onDeletedAccessTokenId: (deletedAccessTokenId: string) => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const router = useRouteSelector();
|
||||
const [deleteCdnAccessToken, mutate] = useMutation(CDNAccessTokenDeleteMutation);
|
||||
|
||||
useEffect(() => {
|
||||
if (deleteCdnAccessToken.data?.deleteCdnAccessToken.ok?.deletedCdnAccessTokenId) {
|
||||
props.onDeletedAccessTokenId(
|
||||
deleteCdnAccessToken.data.deleteCdnAccessToken.ok.deletedCdnAccessTokenId,
|
||||
);
|
||||
}
|
||||
}, [deleteCdnAccessToken.data?.deleteCdnAccessToken.ok?.deletedCdnAccessTokenId ?? null]);
|
||||
|
||||
const onClose = () => props.onClose();
|
||||
|
||||
let body = (
|
||||
<div className="flex flex-1 flex-col items-stretch gap-12">
|
||||
<div className="flex flex-col gap-5">
|
||||
<Heading className="text-center">Delete CDN Access Tokens</Heading>
|
||||
</div>
|
||||
<Tag color="yellow" className="py-2.5 px-4">
|
||||
<AlertTriangleIcon className="h-5 w-5" />
|
||||
Deleting an CDN access token can not be undone. After deleting the access token it might
|
||||
take up to 5 minutes before the changes are propagated across the CDN.
|
||||
</Tag>
|
||||
<p>Are you sure you want to delete the CDN Access Token?</p>
|
||||
|
||||
<div className="mt-auto flex w-full gap-2 self-end">
|
||||
<Button variant="primary" size="large" className="ml-auto" onClick={onClose}>
|
||||
Abort
|
||||
</Button>
|
||||
<Button
|
||||
disabled={deleteCdnAccessToken.fetching}
|
||||
danger
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={() =>
|
||||
mutate({
|
||||
input: {
|
||||
selector: {
|
||||
organization: router.organizationId,
|
||||
project: router.projectId,
|
||||
target: router.targetId,
|
||||
},
|
||||
cdnAccessTokenId: props.cdnAccessTokenId,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (deleteCdnAccessToken.data?.deleteCdnAccessToken.ok) {
|
||||
body = (
|
||||
<div className="flex flex-1 flex-col items-stretch gap-12">
|
||||
<div className="flex flex-col gap-5">
|
||||
<Heading className="text-center">Delete CDN Access Token</Heading>
|
||||
</div>
|
||||
|
||||
<p>The CDN Access Token was successfully deleted.</p>
|
||||
|
||||
<Tag color="yellow" className="py-2.5 px-4">
|
||||
<AlertTriangleIcon className="h-5 w-5" />
|
||||
It can take up to 5 minutes before the changes are propagated across the CDN.
|
||||
</Tag>
|
||||
<div className="mt-auto flex w-full gap-2 self-end">
|
||||
<Button variant="primary" size="large" className="ml-auto" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (deleteCdnAccessToken.data?.deleteCdnAccessToken.error) {
|
||||
body = (
|
||||
<div className="flex flex-1 flex-col items-stretch gap-12">
|
||||
<div className="flex flex-col gap-5">
|
||||
<Heading className="text-center">Delete CDN Access Token</Heading>
|
||||
</div>
|
||||
|
||||
<p>Something went wrong.</p>
|
||||
|
||||
<Tag color="yellow" className="py-2.5 px-4">
|
||||
<AlertTriangleIcon className="h-5 w-5" />
|
||||
{deleteCdnAccessToken.data?.deleteCdnAccessToken.error.message}
|
||||
</Tag>
|
||||
<div className="mt-auto flex w-full gap-2 self-end">
|
||||
<Button variant="primary" size="large" className="ml-auto" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={true} className="w-[650px]" onOpenChange={onClose}>
|
||||
{body}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const CDNAccessTokensQuery = gql(/* GraphQL */ `
|
||||
query CDNAccessTokensQuery($selector: TargetSelectorInput!, $first: Int!, $after: String) {
|
||||
target(selector: $selector) {
|
||||
id
|
||||
cdnAccessTokens(first: $first, after: $after) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...CDNAccessTokens_CdnAccessTokenRowFragment
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const isDeleteCDNAccessTokenModalPath = (path: string): null | string => {
|
||||
const pattern = /#delete-cdn-access-token\?id=(([a-zA-Z-\d]*))$/;
|
||||
|
||||
const result = path.match(pattern);
|
||||
if (result === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result[1];
|
||||
};
|
||||
|
||||
export const CDNAccessTokens = (props: { me: MemberFieldsFragment }): React.ReactElement => {
|
||||
const routerSelector = useRouteSelector();
|
||||
const router = useRouter();
|
||||
|
||||
const [endCursors, setEndCursors] = useState<Array<string>>([]);
|
||||
|
||||
const openCreateCDNAccessTokensModalLink = `${router.asPath}#create-cdn-access-token`;
|
||||
const isCreateCDNAccessTokensModalOpen = router.asPath.endsWith('#create-cdn-access-token');
|
||||
|
||||
const deleteCDNAccessTokenId = useMemo(() => {
|
||||
return isDeleteCDNAccessTokenModalPath(router.asPath);
|
||||
}, [router.asPath]);
|
||||
|
||||
const closeModal = () => {
|
||||
void router.push(router.asPath.split('#')[0], undefined, {
|
||||
scroll: false,
|
||||
});
|
||||
};
|
||||
|
||||
const [target, reexecuteQuery] = useQuery({
|
||||
query: CDNAccessTokensQuery,
|
||||
variables: {
|
||||
selector: {
|
||||
organization: routerSelector.organizationId,
|
||||
project: routerSelector.projectId,
|
||||
target: routerSelector.targetId,
|
||||
},
|
||||
first: 10,
|
||||
after: endCursors[endCursors.length - 1] ?? null,
|
||||
},
|
||||
requestPolicy: 'cache-and-network',
|
||||
});
|
||||
|
||||
const canManage = canAccessTarget(TargetAccessScope.Settings, props.me);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Heading id="cdn-access-tokens" className="mb-2">
|
||||
CDN Access Token
|
||||
</Heading>
|
||||
<p className="mb-3 font-light text-gray-300">
|
||||
Be careful! These tokens allow accessing the schema artifacts of your target.
|
||||
</p>
|
||||
{canManage && (
|
||||
<div className="my-3.5 flex justify-between">
|
||||
<Button
|
||||
as="a"
|
||||
href={openCreateCDNAccessTokensModalLink}
|
||||
variant="secondary"
|
||||
onClick={ev => {
|
||||
ev.preventDefault();
|
||||
void router.push(openCreateCDNAccessTokensModalLink);
|
||||
}}
|
||||
size="large"
|
||||
className="px-5"
|
||||
>
|
||||
Create new CDN Token
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Table
|
||||
dataSource={target?.data?.target?.cdnAccessTokens.edges?.map(edge => ({
|
||||
id: edge.node.id,
|
||||
name:
|
||||
edge.node.firstCharacters + new Array(10).fill('•').join('') + edge.node.lastCharacters,
|
||||
alias: edge.node.alias,
|
||||
createdAt: (
|
||||
<>
|
||||
created <TimeAgo date={edge.node.createdAt} />
|
||||
</>
|
||||
),
|
||||
delete: (
|
||||
<Button
|
||||
className="hover:text-red-500"
|
||||
onClick={() => {
|
||||
void router.push(`${router.asPath}#delete-cdn-access-token?id=${edge.node.id}`);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
),
|
||||
}))}
|
||||
columns={[
|
||||
{ key: 'name' },
|
||||
{ key: 'alias' },
|
||||
{ key: 'createdAt', align: 'right' },
|
||||
{ key: 'delete', align: 'right' },
|
||||
]}
|
||||
/>
|
||||
<div className="my-3.5 flex justify-end">
|
||||
{target.data?.target?.cdnAccessTokens.pageInfo.hasPreviousPage ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
className="mr-2 px-5"
|
||||
onClick={() => {
|
||||
setEndCursors(cursors => {
|
||||
if (cursors.length === 0) {
|
||||
return cursors;
|
||||
}
|
||||
return cursors.slice(0, cursors.length - 1);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Previous Page
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
className="px-5"
|
||||
disabled={!target.data?.target?.cdnAccessTokens.pageInfo.hasNextPage}
|
||||
onClick={() => {
|
||||
setEndCursors(cursors => {
|
||||
if (!target.data?.target?.cdnAccessTokens.pageInfo.endCursor) {
|
||||
return cursors;
|
||||
}
|
||||
return [...cursors, target.data?.target?.cdnAccessTokens.pageInfo.endCursor];
|
||||
});
|
||||
}}
|
||||
>
|
||||
Next Page
|
||||
</Button>
|
||||
</div>
|
||||
{isCreateCDNAccessTokensModalOpen ? (
|
||||
<CreateCDNAccessTokenModal
|
||||
onCreateCDNAccessToken={() => {
|
||||
reexecuteQuery({ requestPolicy: 'network-only' });
|
||||
}}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
) : null}
|
||||
{deleteCDNAccessTokenId ? (
|
||||
<DeleteCDNAccessTokenModal
|
||||
cdnAccessTokenId={deleteCDNAccessTokenId}
|
||||
onDeletedAccessTokenId={() => {
|
||||
reexecuteQuery({ requestPolicy: 'network-only' });
|
||||
}}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -10,6 +10,7 @@ export const Heading = ({
|
|||
children: ReactNode;
|
||||
size?: 'lg' | 'xl' | '2xl';
|
||||
className?: string;
|
||||
id?: string;
|
||||
}): ReactElement => {
|
||||
const HeadingLevel = size === '2xl' ? 'h1' : 'h3';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { ReactElement, useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery } from 'urql';
|
||||
import { ReactElement } from 'react';
|
||||
import { gql, useQuery } from 'urql';
|
||||
import { Button, CopyValue, Heading, Link, Modal, Tag } from '@/components/v2';
|
||||
import { CreateCdnTokenDocument, ProjectDocument, ProjectType } from '@/graphql';
|
||||
import { getDocsUrl } from '@/lib/docs-url';
|
||||
import { useRouteSelector } from '@/lib/hooks';
|
||||
import { Spinner } from '@chakra-ui/react';
|
||||
|
||||
const taxonomy = {
|
||||
[ProjectType.Federation]: 'supergraph schema',
|
||||
[ProjectType.Stitching]: 'services',
|
||||
} as Record<ProjectType, string | undefined>;
|
||||
const ConnectSchemaModalQuery = gql(/* GraphQL */ `
|
||||
query ConnectSchemaModal($targetSelector: TargetSelectorInput!) {
|
||||
target(selector: $targetSelector) {
|
||||
id
|
||||
cdnUrl
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const ConnectSchemaModal = ({
|
||||
isOpen,
|
||||
|
|
@ -18,68 +20,57 @@ export const ConnectSchemaModal = ({
|
|||
isOpen: boolean;
|
||||
toggleModalOpen: () => void;
|
||||
}): ReactElement => {
|
||||
const [generating, setGenerating] = useState(true);
|
||||
const router = useRouteSelector();
|
||||
const [projectQuery] = useQuery({
|
||||
query: ProjectDocument,
|
||||
const [query] = useQuery({
|
||||
query: ConnectSchemaModalQuery,
|
||||
variables: {
|
||||
organizationId: router.organizationId,
|
||||
projectId: router.projectId,
|
||||
},
|
||||
requestPolicy: 'cache-and-network',
|
||||
});
|
||||
const [mutation, mutate] = useMutation(CreateCdnTokenDocument);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setGenerating(true);
|
||||
return;
|
||||
}
|
||||
|
||||
void mutate({
|
||||
selector: {
|
||||
targetSelector: {
|
||||
organization: router.organizationId,
|
||||
project: router.projectId,
|
||||
target: router.targetId,
|
||||
},
|
||||
}).then(() => {
|
||||
setTimeout(() => {
|
||||
setGenerating(false);
|
||||
}, 2000);
|
||||
});
|
||||
}, [isOpen, mutate, router.organizationId, router.projectId, router.targetId]);
|
||||
|
||||
const project = projectQuery.data?.project;
|
||||
},
|
||||
requestPolicy: 'cache-and-network',
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={toggleModalOpen} className="flex w-[650px] flex-col gap-5">
|
||||
<Heading className="text-center">Connect to Hive</Heading>
|
||||
|
||||
{project && generating && (
|
||||
<div className="mt-5 flex flex-col items-center gap-2 px-20">
|
||||
<Spinner />
|
||||
<Heading>Generating access...</Heading>
|
||||
<p className="text-center">
|
||||
Hive is now generating an authentication token and an URL you can use to fetch your{' '}
|
||||
{taxonomy[project.type] ?? 'schema'}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project && !generating && mutation.data && (
|
||||
{query.data?.target && (
|
||||
<>
|
||||
<p className="text-sm text-gray-500">
|
||||
With high-availability and multi-zone CDN service based on Cloudflare, Hive allows you
|
||||
to access the
|
||||
{taxonomy[project.type] ?? 'schema'} of your API, through a secured external service,
|
||||
that's always up regardless of Hive.
|
||||
to access the schema of your API, through a secured external service, that's always up
|
||||
regardless of Hive.
|
||||
</p>
|
||||
<span className="text-sm text-gray-500">You can use the following endpoint:</span>
|
||||
<CopyValue value={mutation.data.createCdnToken.url} />
|
||||
<CopyValue value={query.data.target.cdnUrl} />
|
||||
<span className="text-sm text-gray-500">
|
||||
To authenticate, use the following HTTP headers:
|
||||
To authenticate, use the access HTTP headers. <br />
|
||||
</span>
|
||||
<Tag>X-Hive-CDN-Key: {mutation.data.createCdnToken.token}</Tag>
|
||||
<p className="text-sm text-gray-500">
|
||||
<Tag>
|
||||
X-Hive-CDN-Key: {'<'}Your Access Token{'>'}
|
||||
</Tag>
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
You can manage and generate CDN access tokens in the{' '}
|
||||
<Link
|
||||
variant="primary"
|
||||
href={
|
||||
'/' +
|
||||
[
|
||||
router.organizationId,
|
||||
router.projectId,
|
||||
router.targetId,
|
||||
'settings#cdn-access-tokens',
|
||||
].join('/')
|
||||
}
|
||||
>
|
||||
target settings
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Read the{' '}
|
||||
<Link
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
mutation createCdnToken($selector: TargetSelectorInput!) {
|
||||
createCdnToken(selector: $selector) {
|
||||
url
|
||||
token
|
||||
}
|
||||
}
|
||||
|
|
@ -179,6 +179,7 @@ importers:
|
|||
'@types/ioredis': 4.28.10
|
||||
'@types/jest': 29.2.6
|
||||
'@whatwg-node/fetch': 0.6.2
|
||||
bcryptjs: 2.4.3
|
||||
date-fns: 2.29.3
|
||||
dockerode: 3.3.4
|
||||
dotenv: 16.0.3
|
||||
|
|
@ -197,11 +198,13 @@ importers:
|
|||
'@app/gql': link:testkit/gql
|
||||
'@aws-sdk/client-s3': 3.259.0
|
||||
'@esm2cjs/execa': 6.1.1-cjs.1
|
||||
'@graphql-hive/client': link:../packages/libraries/client
|
||||
'@graphql-hive/core': link:../packages/libraries/core
|
||||
'@graphql-typed-document-node/core': 3.1.1_graphql@16.6.0
|
||||
'@trpc/client': 10.9.0_@trpc+server@10.9.0
|
||||
'@trpc/server': 10.9.0
|
||||
'@whatwg-node/fetch': 0.6.2
|
||||
bcryptjs: 2.4.3
|
||||
date-fns: 2.29.3
|
||||
dockerode: 3.3.4
|
||||
dotenv: 16.0.3
|
||||
|
|
@ -213,7 +216,6 @@ importers:
|
|||
slonik: 30.1.2_wg2hxbo7txnklmvja4aeqnygfi
|
||||
zod: 3.20.2
|
||||
devDependencies:
|
||||
'@graphql-hive/client': link:../packages/libraries/client
|
||||
'@hive/rate-limit': link:../packages/services/rate-limit
|
||||
'@hive/server': link:../packages/services/server
|
||||
'@types/dockerode': 3.3.14
|
||||
|
|
|
|||
|
|
@ -36,8 +36,9 @@
|
|||
"@hive/cdn-script/artifact-storage-reader": [
|
||||
"./packages/services/cdn-worker/src/artifact-storage-reader.ts"
|
||||
],
|
||||
"@hive/cdn-script/aws": ["./packages/services/cdn-worker/src/aws.ts"],
|
||||
"@hive/cdn-script/cdn-token": ["./packages/services/cdn-worker/src/cdn-token.ts"],
|
||||
"@hive/cdn-script/key-validation": ["./packages/services/cdn-worker/src/key-validation.ts"],
|
||||
"@hive/cdn-script/aws": ["./packages/services/cdn-worker/src/aws.ts"],
|
||||
"@hive/server": ["./packages/services/server/src/api.ts"],
|
||||
"@hive/stripe-billing": ["./packages/services/stripe-billing/src/api.ts"],
|
||||
"@hive/schema": ["./packages/services/schema/src/api.ts"],
|
||||
|
|
|
|||
Loading…
Reference in a new issue