Added project pages and cards, most of the screens in chris samuels figma design document. Still need to implement project initialization modal and walkthrough, connect to backend and connect to wallet (maybe be beyond scope of this project)

This commit is contained in:
2025-05-06 16:42:13 -04:00
parent 15bba92e2e
commit 69b8cf1395
392 changed files with 45602 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
# backend
This backend is a [node.js](https://nodejs.org/) [express.js](https://expressjs.com/) [apollo server](https://www.apollographql.com/docs/apollo-server/) project in a [yarn workspace](https://yarnpkg.com/features/workspaces).
## Getting Started
### Install dependencies
In the root of the project, run:
```zsh
yarn
```
### Build backend
```zsh
yarn build --ignore frontend
```
### Environment variables
#### Local
Copy the `environments/local.toml.example` file to `environments/local.toml`:
```zsh
cp environments/local.toml.example environments/local.toml
```
#### Staging environment variables
In the deployment repository, update staging [staging/configmaps/config/prod.toml](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments/src/commit/318c2bc09f334dca79c3501838512749f9431bf1/deployments/staging/configmaps/config/prod.toml)
#### Production environment variables
In the deployment repository, update production [production/configmaps/config/prod.toml](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments/src/commit/318c2bc09f334dca79c3501838512749f9431bf1/deployments/production/configmaps/config/prod.toml)
### Run development server
```zsh
yarn start
```
## Deployment
Clone the [deployer repository](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments):
```zsh
git clone git@git.vdb.to:cerc-io/snowballtools-base-api-deployments.git
```
### Staging
```zsh
echo trigger >> .gitea/workflows/triggers/staging-deploy
git commit -a -m "Deploy v0.0.8" # replace with version number
git push
```
### Production
```zsh
echo trigger >> .gitea/workflows/triggers/production-deploy
git commit -a -m "Deploy v0.0.8" # replace with version number
git push
```
### Deployment status
Dumb for now
- [Staging](https://snowballtools-base-api.staging.apps.snowballtools.com/staging/version)
- [Production](https://snowballtools-base-api.apps.snowballtools.com/staging/version)
Update version number manually in [routes/staging.ts](/packages/backend/src/routes/staging.ts)
+32
View File
@@ -0,0 +1,32 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "off"
},
"style": {
"noNonNullAssertion": "off"
}
}
},
"javascript": {
"formatter": {
"enabled": true,
"quoteStyle": "single",
"trailingCommas": "none",
"semicolons": "asNeeded"
}
},
"files": {
"ignore": ["dist/**/*", "node_modules/**/*", ".turbo/**/*"]
}
}
+43
View File
@@ -0,0 +1,43 @@
[server]
host = "127.0.0.1"
port = 8000
gqlPath = "/graphql"
[server.session]
secret = ""
# Frontend webapp URL origin
appOriginUrl = "http://localhost:3000"
# Set to true if server running behind proxy
trustProxy = false
# Backend URL hostname
domain = "localhost"
[database]
dbPath = "db/snowball"
[gitHub]
webhookUrl = ""
[gitHub.oAuth]
clientId = ""
clientSecret = ""
[registryConfig]
fetchDeploymentRecordDelay = 5000
checkAuctionStatusDelay = 5000
restEndpoint = "http://localhost:1317"
gqlEndpoint = "http://localhost:9473/api"
chainId = "laconic_9000-1"
privateKey = ""
bondId = ""
authority = ""
[registryConfig.fee]
gas = ""
fees = ""
gasPrice = "1alnt"
# Durations are set to 2 mins as deployers may take time with ongoing deployments and auctions
[auction]
commitFee = "100000"
commitsDuration = "120s"
revealFee = "100000"
revealsDuration = "120s"
denom = "alnt"
@@ -0,0 +1,43 @@
[server]
host = "127.0.0.1"
port = 8000
gqlPath = "/graphql"
[server.session]
secret = ""
# Frontend webapp URL origin
appOriginUrl = "http://localhost:3000"
# Set to true if server running behind proxy
trustProxy = false
# Backend URL hostname
domain = "localhost"
[database]
dbPath = "db/snowball"
[gitHub]
webhookUrl = ""
[gitHub.oAuth]
clientId = ""
clientSecret = ""
[registryConfig]
fetchDeploymentRecordDelay = 5000
checkAuctionStatusDelay = 5000
restEndpoint = "http://localhost:1317"
gqlEndpoint = "http://localhost:9473/api"
chainId = "laconic_9000-1"
privateKey = ""
bondId = ""
authority = ""
[registryConfig.fee]
gas = ""
fees = ""
gasPrice = "1alnt"
# Durations are set to 2 mins as deployers may take time with ongoing deployments and auctions
[auction]
commitFee = "100000"
commitsDuration = "120s"
revealFee = "100000"
revealsDuration = "120s"
denom = "alnt"
+68
View File
@@ -0,0 +1,68 @@
{
"name": "@qwrk/backend",
"license": "UNLICENSED",
"version": "1.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"dependencies": {
"@cerc-io/registry-sdk": "^0.2.11",
"@cosmjs/stargate": "^0.33.0",
"@graphql-tools/schema": "^10.0.2",
"@graphql-tools/utils": "^10.0.12",
"@octokit/oauth-app": "^6.1.0",
"@turnkey/sdk-server": "^0.1.0",
"@types/debug": "^4.1.5",
"@types/node": "^20.11.0",
"@types/semver": "^7.5.8",
"apollo-server-core": "^3.13.0",
"apollo-server-express": "^3.13.0",
"cookie-session": "^2.1.0",
"cors": "^2.8.5",
"debug": "^4.3.1",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"express-session": "^1.18.0",
"fs-extra": "^11.2.0",
"graphql": "^16.8.1",
"luxon": "^3.5.0",
"nanoid": "3",
"nanoid-dictionary": "^5.0.0-beta.1",
"octokit": "^3.1.2",
"openpgp": "^6.0.1",
"reflect-metadata": "^0.2.1",
"semver": "^7.6.0",
"siwe": "^3.0.0",
"toml": "^3.0.0",
"ts-node": "^10.9.2",
"typeorm": "^0.3.19",
"typescript": "^5.3.3"
},
"scripts": {
"start": "DEBUG=snowball:* node --enable-source-maps ./dist/index.js",
"start:dev": "DEBUG=snowball:* ts-node ./src/index.ts",
"copy-assets": "copyfiles -u 1 src/**/*.gql dist/",
"clean": "rm -rf ./dist",
"build": "pnpm clean && tsc && pnpm copy-assets",
"format": "biome format .",
"format:check": "biome format --check .",
"lint": "biome check .",
"test:registry:init": "DEBUG=snowball:* ts-node ./test/initialize-registry.ts",
"test:registry:publish-deploy-records": "DEBUG=snowball:* ts-node ./test/publish-deploy-records.ts",
"test:registry:publish-deployment-removal-records": "DEBUG=snowball:* ts-node ./test/publish-deployment-removal-records.ts",
"test:db:load:fixtures": "DEBUG=snowball:* ts-node ./test/initialize-db.ts",
"test:db:delete": "DEBUG=snowball:* ts-node ./test/delete-db.ts"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@types/cookie-session": "^2.0.49",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/express-session": "^1.17.10",
"@types/fs-extra": "^11.0.4",
"better-sqlite3": "^9.2.2",
"copyfiles": "^2.4.1",
"prettier": "^3.1.1",
"workspace": "^0.0.1-preview.1"
}
}
+66
View File
@@ -0,0 +1,66 @@
export interface SessionConfig {
secret: string
appOriginUrl: string
trustProxy: boolean
domain: string
}
export interface ServerConfig {
host: string
port: number
gqlPath?: string
sessionSecret: string
appOriginUrl: string
isProduction: boolean
session: SessionConfig
}
export interface DatabaseConfig {
dbPath: string
}
export interface GitHubConfig {
webhookUrl: string
oAuth: {
clientId: string
clientSecret: string
}
}
export interface RegistryConfig {
restEndpoint: string
gqlEndpoint: string
chainId: string
privateKey: string
bondId: string
fetchDeploymentRecordDelay: number
checkAuctionStatusDelay: number
authority: string
fee: {
gas: string
fees: string
gasPrice: string
}
}
export interface AuctionConfig {
commitFee: string
commitsDuration: string
revealFee: string
revealsDuration: string
denom: string
}
export interface Config {
server: ServerConfig
database: DatabaseConfig
gitHub: GitHubConfig
registryConfig: RegistryConfig
auction: AuctionConfig
turnkey: {
apiBaseUrl: string
apiPublicKey: string
apiPrivateKey: string
defaultOrganizationId: string
}
}
+7
View File
@@ -0,0 +1,7 @@
import process from 'node:process'
export const DEFAULT_CONFIG_FILE_PATH =
process.env.SNOWBALL_BACKEND_CONFIG_FILE_PATH ||
'apps/backend/environments/local.toml'
export const DEFAULT_GQL_PATH = '/graphql'
+694
View File
@@ -0,0 +1,694 @@
import assert from 'node:assert'
import path from 'node:path'
import debug from 'debug'
import { customAlphabet } from 'nanoid'
import { lowercase, numbers } from 'nanoid-dictionary'
import {
DataSource,
type DeepPartial,
type FindManyOptions,
type FindOneOptions,
type FindOptionsWhere,
IsNull,
Not
} from 'typeorm'
import type { DatabaseConfig } from './config'
import { Deployer } from './entity/Deployer'
import { Deployment, DeploymentStatus } from './entity/Deployment'
import { Domain } from './entity/Domain'
import { EnvironmentVariable } from './entity/EnvironmentVariable'
import { Organization } from './entity/Organization'
import { Project } from './entity/Project'
import { ProjectMember } from './entity/ProjectMember'
import { User } from './entity/User'
import { UserOrganization } from './entity/UserOrganization'
import type { DNSRecordAttributes } from './types'
import { getEntities, loadAndSaveData } from './utils'
const ORGANIZATION_DATA_PATH = '../test/fixtures/organizations.json'
const log = debug('snowball:database')
const nanoid = customAlphabet(lowercase + numbers, 8)
// TODO: Fix order of methods
export class Database {
private dataSource: DataSource
constructor({ dbPath }: DatabaseConfig) {
this.dataSource = new DataSource({
type: 'better-sqlite3',
database: dbPath,
entities: [path.join(__dirname, '/entity/*')],
synchronize: true,
logging: false
})
}
async init(): Promise<void> {
await this.dataSource.initialize()
log('database initialized')
let organizations = await this.getOrganizations({})
// Load an organization if none exist
if (!organizations.length) {
const orgEntities = await getEntities(
path.resolve(__dirname, ORGANIZATION_DATA_PATH)
)
organizations = await loadAndSaveData(Organization, this.dataSource, [
orgEntities[0]
])
}
// Hotfix for updating old DB data
if (organizations[0].slug === 'snowball-tools-1') {
const [orgEntity] = await getEntities(
path.resolve(__dirname, ORGANIZATION_DATA_PATH)
)
await this.updateOrganization(organizations[0].id, {
slug: orgEntity.slug as string,
name: orgEntity.name as string
})
}
}
async getUser(options: FindOneOptions<User>): Promise<User | null> {
const userRepository = this.dataSource.getRepository(User)
const user = await userRepository.findOne(options)
return user
}
async addUser(data: DeepPartial<User>): Promise<User> {
const userRepository = this.dataSource.getRepository(User)
const user = await userRepository.save(data)
return user
}
async updateUser(user: User, data: DeepPartial<User>): Promise<boolean> {
const userRepository = this.dataSource.getRepository(User)
const updateResult = await userRepository.update({ id: user.id }, data)
assert(updateResult.affected)
return updateResult.affected > 0
}
async getOrganizations(
options: FindManyOptions<Organization>
): Promise<Organization[]> {
const organizationRepository = this.dataSource.getRepository(Organization)
const organizations = await organizationRepository.find(options)
return organizations
}
async getOrganization(
options: FindOneOptions<Organization>
): Promise<Organization | null> {
const organizationRepository = this.dataSource.getRepository(Organization)
const organization = await organizationRepository.findOne(options)
return organization
}
async getOrganizationsByUserId(userId: string): Promise<Organization[]> {
const organizationRepository = this.dataSource.getRepository(Organization)
const userOrgs = await organizationRepository.find({
where: {
userOrganizations: {
member: {
id: userId
}
}
}
})
return userOrgs
}
async addUserOrganization(
data: DeepPartial<UserOrganization>
): Promise<UserOrganization> {
const userOrganizationRepository =
this.dataSource.getRepository(UserOrganization)
const newUserOrganization = await userOrganizationRepository.save(data)
return newUserOrganization
}
async updateOrganization(
organizationId: string,
data: DeepPartial<Organization>
): Promise<boolean> {
const organizationRepository = this.dataSource.getRepository(Organization)
const updateResult = await organizationRepository.update(
{ id: organizationId },
data
)
assert(updateResult.affected)
return updateResult.affected > 0
}
async getProjects(options: FindManyOptions<Project>): Promise<Project[]> {
const projectRepository = this.dataSource.getRepository(Project)
const projects = await projectRepository.find(options)
return projects
}
async getProjectById(projectId: string): Promise<Project | null> {
const projectRepository = this.dataSource.getRepository(Project)
const project = await projectRepository
.createQueryBuilder('project')
.leftJoinAndSelect(
'project.deployments',
'deployments',
'deployments.isCurrent = true AND deployments.isCanonical = true'
)
.leftJoinAndSelect('deployments.createdBy', 'user')
.leftJoinAndSelect('deployments.deployer', 'deployer')
.leftJoinAndSelect('project.owner', 'owner')
.leftJoinAndSelect('project.deployers', 'deployers')
.leftJoinAndSelect('project.organization', 'organization')
.where('project.id = :projectId', {
projectId
})
.getOne()
return project
}
async allProjectsWithoutDeployments(): Promise<Project[]> {
const allProjects = await this.getProjects({
where: {
auctionId: Not(IsNull())
},
relations: ['deployments'],
withDeleted: true
})
const projects = allProjects.filter((project) => {
if (project.deletedAt !== null) return false
return project.deployments.length === 0
})
return projects
}
async getProjectsInOrganization(
userId: string,
organizationSlug: string
): Promise<Project[]> {
const projectRepository = this.dataSource.getRepository(Project)
const projects = await projectRepository
.createQueryBuilder('project')
.leftJoinAndSelect(
'project.deployments',
'deployments',
'deployments.isCurrent = true AND deployments.isCanonical = true'
)
.leftJoin('project.projectMembers', 'projectMembers')
.leftJoin('project.organization', 'organization')
.where(
'(project.ownerId = :userId OR projectMembers.userId = :userId) AND organization.slug = :organizationSlug',
{
userId,
organizationSlug
}
)
.getMany()
return projects
}
/**
* Get deployments with specified filter
*/
async getDeployments(
options: FindManyOptions<Deployment>
): Promise<Deployment[]> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const deployments = await deploymentRepository.find(options)
return deployments
}
async getDeploymentsByProjectId(projectId: string): Promise<Deployment[]> {
return this.getDeployments({
relations: {
project: true,
createdBy: true,
deployer: true
},
where: {
project: {
id: projectId
}
},
order: {
createdAt: 'DESC'
}
})
}
async getNonCanonicalDeploymentsByProjectId(
projectId: string
): Promise<Deployment[]> {
return this.getDeployments({
relations: {
project: true,
createdBy: true,
deployer: true
},
where: {
project: {
id: projectId
},
isCanonical: false
},
order: {
createdAt: 'DESC'
}
})
}
async getDeployment(
options: FindOneOptions<Deployment>
): Promise<Deployment | null> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const deployment = await deploymentRepository.findOne(options)
return deployment
}
async getDomains(options: FindManyOptions<Domain>): Promise<Domain[]> {
const domainRepository = this.dataSource.getRepository(Domain)
const domains = await domainRepository.find(options)
return domains
}
async addDeployment(data: DeepPartial<Deployment>): Promise<Deployment> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const id = nanoid()
const updatedData = {
...data,
id
}
const deployment = await deploymentRepository.save(updatedData)
return deployment
}
async getProjectMembersByProjectId(
projectId: string
): Promise<ProjectMember[]> {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
const projectMembers = await projectMemberRepository.find({
relations: {
project: true,
member: true
},
where: {
project: {
id: projectId
}
}
})
return projectMembers
}
async getEnvironmentVariablesByProjectId(
projectId: string,
filter?: FindOptionsWhere<EnvironmentVariable>
): Promise<EnvironmentVariable[]> {
const environmentVariableRepository =
this.dataSource.getRepository(EnvironmentVariable)
const environmentVariables = await environmentVariableRepository.find({
where: {
project: {
id: projectId
},
...filter
}
})
return environmentVariables
}
async removeProjectMemberById(projectMemberId: string): Promise<boolean> {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
const deleteResult = await projectMemberRepository.delete({
id: projectMemberId
})
if (deleteResult.affected) {
return deleteResult.affected > 0
}
return false
}
async updateProjectMemberById(
projectMemberId: string,
data: DeepPartial<ProjectMember>
): Promise<boolean> {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
const updateResult = await projectMemberRepository.update(
{ id: projectMemberId },
data
)
return Boolean(updateResult.affected)
}
async addProjectMember(
data: DeepPartial<ProjectMember>
): Promise<ProjectMember> {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
const newProjectMember = await projectMemberRepository.save(data)
return newProjectMember
}
async addEnvironmentVariables(
data: DeepPartial<EnvironmentVariable>[]
): Promise<EnvironmentVariable[]> {
const environmentVariableRepository =
this.dataSource.getRepository(EnvironmentVariable)
const savedEnvironmentVariables =
await environmentVariableRepository.save(data)
return savedEnvironmentVariables
}
async updateEnvironmentVariable(
environmentVariableId: string,
data: DeepPartial<EnvironmentVariable>
): Promise<boolean> {
const environmentVariableRepository =
this.dataSource.getRepository(EnvironmentVariable)
const updateResult = await environmentVariableRepository.update(
{ id: environmentVariableId },
data
)
return Boolean(updateResult.affected)
}
async deleteEnvironmentVariable(
environmentVariableId: string
): Promise<boolean> {
const environmentVariableRepository =
this.dataSource.getRepository(EnvironmentVariable)
const deleteResult = await environmentVariableRepository.delete({
id: environmentVariableId
})
if (deleteResult.affected) {
return deleteResult.affected > 0
}
return false
}
async getProjectMemberById(projectMemberId: string): Promise<ProjectMember> {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
const projectMemberWithProject = await projectMemberRepository.find({
relations: {
project: {
owner: true
},
member: true
},
where: {
id: projectMemberId
}
})
if (projectMemberWithProject.length === 0) {
throw new Error('Member does not exist')
}
return projectMemberWithProject[0]
}
async getProjectsBySearchText(
userId: string,
searchText: string
): Promise<Project[]> {
const projectRepository = this.dataSource.getRepository(Project)
const projects = await projectRepository
.createQueryBuilder('project')
.leftJoinAndSelect('project.organization', 'organization')
.leftJoin('project.projectMembers', 'projectMembers')
.where(
'(project.owner = :userId OR projectMembers.member.id = :userId) AND project.name LIKE :searchText',
{
userId,
searchText: `%${searchText}%`
}
)
.getMany()
return projects
}
async updateDeploymentById(
deploymentId: string,
data: DeepPartial<Deployment>
): Promise<boolean> {
return this.updateDeployment({ id: deploymentId }, data)
}
async updateDeployment(
criteria: FindOptionsWhere<Deployment>,
data: DeepPartial<Deployment>
): Promise<boolean> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const updateResult = await deploymentRepository.update(criteria, data)
return Boolean(updateResult.affected)
}
async updateDeploymentsByProjectIds(
projectIds: string[],
data: DeepPartial<Deployment>
): Promise<boolean> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const updateResult = await deploymentRepository
.createQueryBuilder()
.update(Deployment)
.set(data)
.where('projectId IN (:...projectIds)', { projectIds })
.execute()
return Boolean(updateResult.affected)
}
async deleteDeploymentById(deploymentId: string): Promise<boolean> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const deployment = await deploymentRepository.findOneOrFail({
where: {
id: deploymentId
}
})
const deleteResult = await deploymentRepository.softRemove(deployment)
return Boolean(deleteResult)
}
async addProject(
user: User,
organizationId: string,
data: DeepPartial<Project>
): Promise<Project> {
const projectRepository = this.dataSource.getRepository(Project)
// TODO: Check if organization exists
const newProject = projectRepository.create(data)
// TODO: Set default empty array for webhooks in TypeORM
newProject.webhooks = []
// TODO: Set icon according to framework
newProject.icon = ''
newProject.owner = user
newProject.organization = Object.assign(new Organization(), {
id: organizationId
})
return projectRepository.save(newProject)
}
async saveProject(project: Project): Promise<Project> {
const projectRepository = this.dataSource.getRepository(Project)
return projectRepository.save(project)
}
async updateProjectById(
projectId: string,
data: DeepPartial<Project>
): Promise<boolean> {
const projectRepository = this.dataSource.getRepository(Project)
const updateResult = await projectRepository.update({ id: projectId }, data)
return Boolean(updateResult.affected)
}
async deleteProjectById(projectId: string): Promise<boolean> {
const projectRepository = this.dataSource.getRepository(Project)
const project = await projectRepository.findOneOrFail({
where: {
id: projectId
},
relations: {
projectMembers: true
}
})
const deleteResult = await projectRepository.softRemove(project)
return Boolean(deleteResult)
}
async deleteDomainById(domainId: string): Promise<boolean> {
const domainRepository = this.dataSource.getRepository(Domain)
const deleteResult = await domainRepository.softDelete({ id: domainId })
if (deleteResult.affected) {
return deleteResult.affected > 0
}
return false
}
async addDomain(data: DeepPartial<Domain>): Promise<Domain> {
const domainRepository = this.dataSource.getRepository(Domain)
const newDomain = await domainRepository.save(data)
return newDomain
}
async getDomain(options: FindOneOptions<Domain>): Promise<Domain | null> {
const domainRepository = this.dataSource.getRepository(Domain)
const domain = await domainRepository.findOne(options)
return domain
}
async updateDomainById(
domainId: string,
data: DeepPartial<Domain>
): Promise<boolean> {
const domainRepository = this.dataSource.getRepository(Domain)
const updateResult = await domainRepository.update({ id: domainId }, data)
return Boolean(updateResult.affected)
}
async getDomainsByProjectId(
projectId: string,
filter?: FindOptionsWhere<Domain>
): Promise<Domain[]> {
const domainRepository = this.dataSource.getRepository(Domain)
const domains = await domainRepository.find({
relations: {
redirectTo: true
},
where: {
project: {
id: projectId
},
...filter
}
})
return domains
}
async getOldestDomainByProjectId(projectId: string): Promise<Domain | null> {
const domainRepository = this.dataSource.getRepository(Domain)
const domain = await domainRepository.findOne({
where: {
project: {
id: projectId
}
},
order: {
createdAt: 'ASC'
}
})
return domain
}
async getLatestDNSRecordByProjectId(
projectId: string
): Promise<DNSRecordAttributes | null> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const deployment = await deploymentRepository.findOne({
where: {
project: {
id: projectId
},
status: DeploymentStatus.Ready
},
order: {
createdAt: 'DESC'
}
})
if (deployment === null) {
throw new Error(`No deployment found for project ${projectId}`)
}
return deployment.dnsRecordData
}
async addDeployer(data: DeepPartial<Deployer>): Promise<Deployer> {
const deployerRepository = this.dataSource.getRepository(Deployer)
const newDomain = await deployerRepository.save(data)
return newDomain
}
async getDeployers(): Promise<Deployer[]> {
const deployerRepository = this.dataSource.getRepository(Deployer)
const deployers = await deployerRepository.find()
return deployers
}
async getDeployerByLRN(deployerLrn: string): Promise<Deployer | null> {
const deployerRepository = this.dataSource.getRepository(Deployer)
const deployer = await deployerRepository.findOne({
where: { deployerLrn }
})
return deployer
}
}
+32
View File
@@ -0,0 +1,32 @@
import { Column, Entity, ManyToMany, PrimaryColumn } from 'typeorm'
import { Project } from './Project'
@Entity()
export class Deployer {
@PrimaryColumn('varchar')
deployerLrn!: string
@Column('varchar')
deployerId!: string
@Column('varchar')
deployerApiUrl!: string
@Column('varchar')
baseDomain!: string
@Column('varchar', { nullable: true })
publicKey!: string | null
@Column('varchar', { nullable: true })
minimumPayment!: string | null
@Column('varchar', { nullable: true })
paymentAddress!: string | null
@ManyToMany(
() => Project,
(project) => project.deployers
)
projects!: Project[]
}
+159
View File
@@ -0,0 +1,159 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn
} from 'typeorm'
import type {
AppDeploymentRecordAttributes,
AppDeploymentRemovalRecordAttributes,
DNSRecordAttributes
} from '../types'
import { Deployer } from './Deployer'
import { Project } from './Project'
import { User } from './User'
export enum Environment {
Production = 'Production',
Preview = 'Preview',
Development = 'Development'
}
export enum DeploymentStatus {
Building = 'Building',
Ready = 'Ready',
Error = 'Error',
Deleting = 'Deleting'
}
export interface ApplicationDeploymentRequest {
type: string
version: string
name: string
application: string
lrn?: string
auction?: string
config: string
meta: string
payment?: string
dns?: string
}
export interface ApplicationDeploymentRemovalRequest {
type: string
version: string
deployment: string
auction?: string
payment?: string
}
export interface ApplicationRecord {
type: string
version: string
name: string
description?: string
homepage?: string
license?: string
author?: string
repository?: string[]
app_version?: string
repository_ref: string
app_type: string
}
@Entity()
export class Deployment {
// TODO: set custom generated id
@PrimaryColumn('varchar')
id!: string
@Column()
projectId!: string
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'projectId' })
project!: Project
@Column('varchar')
branch!: string
@Column('varchar')
commitHash!: string
@Column('varchar')
commitMessage!: string
@Column('varchar', { nullable: true })
url!: string | null
@Column('varchar')
applicationRecordId!: string
@Column('simple-json')
applicationRecordData!: ApplicationRecord
@Column('varchar', { nullable: true })
applicationDeploymentRequestId!: string | null
@Column('simple-json', { nullable: true })
applicationDeploymentRequestData!: ApplicationDeploymentRequest | null
@Column('varchar', { nullable: true })
applicationDeploymentRecordId!: string | null
@Column('simple-json', { nullable: true })
applicationDeploymentRecordData!: AppDeploymentRecordAttributes | null
@Column('varchar', { nullable: true })
applicationDeploymentRemovalRequestId!: string | null
@Column('simple-json', { nullable: true })
applicationDeploymentRemovalRequestData!: ApplicationDeploymentRemovalRequest | null
@Column('varchar', { nullable: true })
applicationDeploymentRemovalRecordId!: string | null
@Column('simple-json', { nullable: true })
applicationDeploymentRemovalRecordData!: AppDeploymentRemovalRecordAttributes | null
@Column('simple-json', { nullable: true })
dnsRecordData!: DNSRecordAttributes | null
@ManyToOne(() => Deployer)
@JoinColumn({ name: 'deployerLrn' })
deployer!: Deployer
@Column({
enum: Environment
})
environment!: Environment
@Column('boolean', { default: false })
isCurrent!: boolean
@Column('boolean', { default: false })
isCanonical!: boolean
@Column({
enum: DeploymentStatus
})
status!: DeploymentStatus
@ManyToOne(() => User)
@JoinColumn({ name: 'createdBy' })
createdBy!: User
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@DeleteDateColumn()
deletedAt!: Date | null
}
+59
View File
@@ -0,0 +1,59 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn
} from 'typeorm'
import { Project } from './Project'
export enum Status {
Live = 'Live',
Pending = 'Pending'
}
@Entity()
export class Domain {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column('varchar')
projectId!: string
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'projectId' })
project!: Project
@Column('varchar', { length: 255, default: 'main' })
branch!: string
@Column('varchar', { length: 255 })
name!: string
@Column('string', { nullable: true })
redirectToId!: string | null
@ManyToOne(() => Domain)
@JoinColumn({ name: 'redirectToId' })
// eslint-disable-next-line no-use-before-define
redirectTo!: Domain | null
@Column({
enum: Status,
default: Status.Pending
})
status!: Status
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@DeleteDateColumn()
deletedAt!: Date | null
}
@@ -0,0 +1,44 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn
} from 'typeorm'
import { Project } from './Project'
enum Environment {
Production = 'Production',
Preview = 'Preview',
Development = 'Development'
}
@Entity()
export class EnvironmentVariable {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'projectId' })
project!: Project
@Column({
enum: Environment
})
environment!: Environment
@Column('varchar')
key!: string
@Column('varchar')
value!: string
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
}
+38
View File
@@ -0,0 +1,38 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn
} from 'typeorm'
import { UserOrganization } from './UserOrganization'
@Entity()
@Unique(['slug'])
export class Organization {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column('varchar', { length: 255 })
name!: string
@Column('varchar')
slug!: string
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@OneToMany(
() => UserOrganization,
(userOrganization) => userOrganization.organization,
{
cascade: ['soft-remove']
}
)
userOrganizations!: UserOrganization[]
}
+111
View File
@@ -0,0 +1,111 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn
} from 'typeorm'
import { Deployer } from './Deployer'
import { Deployment } from './Deployment'
import { Organization } from './Organization'
import { ProjectMember } from './ProjectMember'
import { User } from './User'
@Entity()
export class Project {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User)
@JoinColumn({ name: 'ownerId' })
owner!: User
@Column({ nullable: false })
ownerId!: string
@ManyToOne(() => Organization, { nullable: true })
@JoinColumn({ name: 'organizationId' })
organization!: Organization | null
@Column('varchar')
organizationId!: string
@Column('varchar')
name!: string
@Column('varchar')
repository!: string
@Column('varchar', { length: 255, default: 'main' })
prodBranch!: string
@Column('text', { default: '' })
description!: string
@Column('varchar', { nullable: true })
auctionId!: string | null
// Tx hash for sending coins from snowball to deployer
@Column('varchar', { nullable: true })
txHash!: string | null
@ManyToMany(
() => Deployer,
(deployer) => deployer.projects
)
@JoinTable()
deployers!: Deployer[]
@Column('boolean', { default: false, nullable: true })
fundsReleased!: boolean
// TODO: Compute template & framework in import repository
@Column('varchar', { nullable: true })
template!: string | null
@Column('varchar', { nullable: true })
framework!: string | null
// Address of the user who created the project i.e. requested deployments
@Column('varchar')
paymentAddress!: string
@Column({
type: 'simple-array'
})
webhooks!: string[]
@Column('varchar')
icon!: string
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@DeleteDateColumn()
deletedAt!: Date | null
@OneToMany(
() => Deployment,
(deployment) => deployment.project
)
deployments!: Deployment[]
@OneToMany(
() => ProjectMember,
(projectMember) => projectMember.project,
{
cascade: ['soft-remove']
}
)
projectMembers!: ProjectMember[]
}
+57
View File
@@ -0,0 +1,57 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn
} from 'typeorm'
import { Project } from './Project'
import { User } from './User'
export enum Permission {
View = 'View',
Edit = 'Edit'
}
@Entity()
@Unique(['project', 'member'])
export class ProjectMember {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(
() => User,
(user) => user.projectMembers
)
@JoinColumn({ name: 'userId' })
member!: User
@ManyToOne(
() => Project,
(project) => project.projectMembers
)
@JoinColumn({ name: 'projectId' })
project!: Project
@Column({
type: 'simple-array'
})
permissions!: Permission[]
@Column('boolean', { default: false })
isPending!: boolean
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@DeleteDateColumn()
deletedAt!: Date | null
}
+65
View File
@@ -0,0 +1,65 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
Unique
} from 'typeorm'
import { ProjectMember } from './ProjectMember'
import { UserOrganization } from './UserOrganization'
@Entity()
@Unique(['email'])
@Unique(['ethAddress'])
export class User {
@PrimaryGeneratedColumn('uuid')
id!: string
// TODO: Set ethAddress as ID
@Column()
ethAddress!: string
@Column('varchar', { length: 255, nullable: true })
name!: string | null
@Column()
email!: string
@Column('varchar', { nullable: true })
gitHubToken!: string | null
@Column('boolean', { default: false })
isVerified!: boolean
@CreateDateColumn()
createdAt!: Date
@CreateDateColumn()
updatedAt!: Date
@Column()
subOrgId!: string
@Column()
turnkeyWalletId!: string
@OneToMany(
() => ProjectMember,
(projectMember) => projectMember.project,
{
cascade: ['soft-remove']
}
)
projectMembers!: ProjectMember[]
@OneToMany(
() => UserOrganization,
(UserOrganization) => UserOrganization.member,
{
cascade: ['soft-remove']
}
)
userOrganizations!: UserOrganization[]
}
@@ -0,0 +1,47 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn
} from 'typeorm'
import { Organization } from './Organization'
import { User } from './User'
export enum Role {
Owner = 'Owner',
Maintainer = 'Maintainer',
Reader = 'Reader'
}
@Entity()
export class UserOrganization {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User)
@JoinColumn({ name: 'userId' })
member!: User
@ManyToOne(() => Organization)
@JoinColumn({ name: 'organizationId' })
organization!: Organization
@Column({
enum: Role
})
role!: Role
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@DeleteDateColumn()
deletedAt!: Date | null
}
+53
View File
@@ -0,0 +1,53 @@
import 'express-async-errors'
import 'reflect-metadata'
import fs from 'node:fs'
import path from 'node:path'
import debug from 'debug'
import { OAuthApp } from '@octokit/oauth-app'
import { Database } from './database'
import { Registry } from './registry'
import { createResolvers } from './resolvers'
import { createAndStartServer } from './server'
import { Service } from './service'
import { getConfig } from './utils'
const log = debug('snowball:server')
const OAUTH_CLIENT_TYPE = 'oauth-app'
export const main = async (): Promise<void> => {
const { server, database, gitHub, registryConfig } = await getConfig()
const app = new OAuthApp({
clientType: OAUTH_CLIENT_TYPE,
clientId: gitHub.oAuth.clientId,
clientSecret: gitHub.oAuth.clientSecret
})
const db = new Database(database)
await db.init()
const registry = new Registry(registryConfig)
const service = new Service(
{ gitHubConfig: gitHub, registryConfig },
db,
app,
registry
)
const typeDefs = fs
.readFileSync(path.join(__dirname, 'schema.gql'))
.toString()
const resolvers = await createResolvers(service)
await createAndStartServer(server, typeDefs, resolvers, service)
}
main()
.then(() => {
log('Starting server...')
})
.catch((err) => {
log(err)
})
+624
View File
@@ -0,0 +1,624 @@
import assert from 'node:assert'
import debug from 'debug'
import { DateTime } from 'luxon'
import type { Octokit } from 'octokit'
import * as openpgp from 'openpgp'
import { inc as semverInc } from 'semver'
import type { DeepPartial } from 'typeorm'
import {
Account,
DEFAULT_GAS_ESTIMATION_MULTIPLIER,
Registry as LaconicRegistry,
getGasPrice,
parseGasAndFees
} from '@cerc-io/registry-sdk'
import type { DeliverTxResponse, IndexedTx } from '@cosmjs/stargate'
import type { RegistryConfig } from './config'
import type {
ApplicationDeploymentRemovalRequest,
ApplicationDeploymentRequest,
ApplicationRecord,
Deployment
} from './entity/Deployment'
import type {
AppDeploymentRecord,
AppDeploymentRemovalRecord,
AuctionParams,
DeployerRecord,
RegistryRecord
} from './types'
import {
getConfig,
getRepoDetails,
registryTransactionWithRetry,
sleep
} from './utils'
const log = debug('snowball:registry')
const APP_RECORD_TYPE = 'ApplicationRecord'
const APP_DEPLOYMENT_AUCTION_RECORD_TYPE = 'ApplicationDeploymentAuction'
const APP_DEPLOYMENT_REQUEST_TYPE = 'ApplicationDeploymentRequest'
const APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE =
'ApplicationDeploymentRemovalRequest'
const APP_DEPLOYMENT_RECORD_TYPE = 'ApplicationDeploymentRecord'
const APP_DEPLOYMENT_REMOVAL_RECORD_TYPE = 'ApplicationDeploymentRemovalRecord'
const WEBAPP_DEPLOYER_RECORD_TYPE = 'WebappDeployer'
const SLEEP_DURATION = 1000
// TODO: Move registry code to registry-sdk/watcher-ts
export class Registry {
private registry: LaconicRegistry
private registryConfig: RegistryConfig
constructor(registryConfig: RegistryConfig) {
this.registryConfig = registryConfig
const gasPrice = getGasPrice(registryConfig.fee.gasPrice)
this.registry = new LaconicRegistry(
registryConfig.gqlEndpoint,
registryConfig.restEndpoint,
{ chainId: registryConfig.chainId, gasPrice }
)
}
async createApplicationRecord({
octokit,
repository,
commitHash,
appType
}: {
octokit: Octokit
repository: string
commitHash: string
appType: string
}): Promise<{
applicationRecordId: string
applicationRecordData: ApplicationRecord
}> {
const { repo, repoUrl, packageJSON } = await getRepoDetails(
octokit,
repository,
commitHash
)
// Use registry-sdk to publish record
// Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts/publish-app-record.sh
// Fetch previous records
const records = await this.registry.queryRecords(
{
type: APP_RECORD_TYPE,
name: packageJSON.name
},
true
)
// Get next version of record
const bondRecords = records.filter(
(record: any) => record.bondId === this.registryConfig.bondId
)
const [latestBondRecord] = bondRecords.sort(
(a: any, b: any) =>
new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
)
const nextVersion = semverInc(
latestBondRecord?.attributes.version ?? '0.0.0',
'patch'
)
assert(nextVersion, 'Application record version not valid')
// Create record of type ApplicationRecord and publish
const applicationRecord = {
type: APP_RECORD_TYPE,
version: nextVersion,
repository_ref: commitHash,
repository: [repoUrl],
app_type: appType,
name: repo,
...(packageJSON.description && { description: packageJSON.description }),
...(packageJSON.homepage && { homepage: packageJSON.homepage }),
...(packageJSON.license && { license: packageJSON.license }),
...(packageJSON.author && {
author:
typeof packageJSON.author === 'object'
? JSON.stringify(packageJSON.author)
: packageJSON.author
}),
...(packageJSON.version && { app_version: packageJSON.version })
}
const result = await this.publishRecord(applicationRecord)
log(`Published application record ${result.id}`)
log('Application record data:', applicationRecord)
// TODO: Discuss computation of LRN
const lrn = this.getLrn(repo)
log(`Setting name: ${lrn} for record ID: ${result.id}`)
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees
)
await sleep(SLEEP_DURATION)
await registryTransactionWithRetry(() =>
this.registry.setName(
{
cid: result.id,
lrn
},
this.registryConfig.privateKey,
fee
)
)
await sleep(SLEEP_DURATION)
await registryTransactionWithRetry(() =>
this.registry.setName(
{
cid: result.id,
lrn: `${lrn}@${applicationRecord.app_version}`
},
this.registryConfig.privateKey,
fee
)
)
await sleep(SLEEP_DURATION)
await registryTransactionWithRetry(() =>
this.registry.setName(
{
cid: result.id,
lrn: `${lrn}@${applicationRecord.repository_ref}`
},
this.registryConfig.privateKey,
fee
)
)
return {
applicationRecordId: result.id,
applicationRecordData: applicationRecord
}
}
async createApplicationDeploymentAuction(
appName: string,
octokit: Octokit,
auctionParams: AuctionParams,
data: DeepPartial<Deployment>
): Promise<{
applicationDeploymentAuctionId: string
}> {
assert(data.project?.repository, 'Project repository not found')
await this.createApplicationRecord({
octokit,
repository: data.project.repository,
appType: data.project!.template!,
commitHash: data.commitHash!
})
const lrn = this.getLrn(appName)
const config = await getConfig()
const auctionConfig = config.auction
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees
)
const auctionResult = await registryTransactionWithRetry(() =>
this.registry.createProviderAuction(
{
commitFee: auctionConfig.commitFee,
commitsDuration: auctionConfig.commitsDuration,
revealFee: auctionConfig.revealFee,
revealsDuration: auctionConfig.revealsDuration,
denom: auctionConfig.denom,
maxPrice: auctionParams.maxPrice,
numProviders: auctionParams.numProviders
},
this.registryConfig.privateKey,
fee
)
)
if (!auctionResult.auction) {
throw new Error('Error creating auction')
}
// Create record of type applicationDeploymentAuction and publish
const applicationDeploymentAuction = {
application: lrn,
auction: auctionResult.auction.id,
type: APP_DEPLOYMENT_AUCTION_RECORD_TYPE
}
const result = await this.publishRecord(applicationDeploymentAuction)
log(`Application deployment auction created: ${auctionResult.auction.id}`)
log(`Application deployment auction record published: ${result.id}`)
log('Application deployment auction data:', applicationDeploymentAuction)
return {
applicationDeploymentAuctionId: auctionResult.auction.id
}
}
async createApplicationDeploymentRequest(data: {
deployment: Deployment
appName: string
repository: string
auctionId?: string | null
lrn: string
apiUrl: string
environmentVariables: { [key: string]: string }
dns: string
requesterAddress: string
publicKey: string
payment?: string | null
}): Promise<{
applicationDeploymentRequestId: string
applicationDeploymentRequestData: ApplicationDeploymentRequest
}> {
const lrn = this.getLrn(data.appName)
const records = await this.registry.resolveNames([lrn])
const applicationRecord = records[0]
if (!applicationRecord) {
throw new Error(`No record found for ${lrn}`)
}
let hash: string | undefined
if (Object.keys(data.environmentVariables).length !== 0) {
hash = await this.generateConfigHash(
data.environmentVariables,
data.requesterAddress,
data.publicKey,
data.apiUrl
)
}
// Create record of type ApplicationDeploymentRequest and publish
const applicationDeploymentRequest = {
type: APP_DEPLOYMENT_REQUEST_TYPE,
version: '1.0.0',
name: `${applicationRecord.attributes.name}@${applicationRecord.attributes.app_version}`,
application: `${lrn}@${applicationRecord.attributes.app_version}`,
dns: data.dns,
// https://git.vdb.to/cerc-io/laconic-registry-cli/commit/129019105dfb93bebcea02fde0ed64d0f8e5983b
config: JSON.stringify(hash ? { ref: hash } : {}),
meta: JSON.stringify({
note: `Added by Snowball @ ${DateTime.utc().toFormat(
"EEE LLL dd HH:mm:ss 'UTC' yyyy"
)}`,
repository: data.repository,
repository_ref: data.deployment.commitHash
}),
deployer: data.lrn,
...(data.auctionId && { auction: data.auctionId }),
...(data.payment && { payment: data.payment })
}
await sleep(SLEEP_DURATION)
const result = await this.publishRecord(applicationDeploymentRequest)
log(`Application deployment request record published: ${result.id}`)
log('Application deployment request data:', applicationDeploymentRequest)
return {
applicationDeploymentRequestId: result.id,
applicationDeploymentRequestData: applicationDeploymentRequest
}
}
async getAuctionWinningDeployerRecords(
auctionId: string
): Promise<DeployerRecord[]> {
const records = await this.registry.getAuctionsByIds([auctionId])
const auctionResult = records[0]
const deployerRecords = []
const { winnerAddresses } = auctionResult
for (const auctionWinner of winnerAddresses) {
const records = await this.getDeployerRecordsByFilter({
paymentAddress: auctionWinner
})
const newRecords = records.filter((record) => {
return record.names !== null && record.names.length > 0
})
for (const record of newRecords) {
if (record.id) {
deployerRecords.push(record)
break
}
}
}
return deployerRecords
}
async releaseDeployerFunds(auctionId: string): Promise<any> {
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees
)
const auction = await registryTransactionWithRetry(() =>
this.registry.releaseFunds(
{
auctionId
},
this.registryConfig.privateKey,
fee
)
)
return auction
}
/**
* Fetch ApplicationDeploymentRecords for deployments
*/
async getDeploymentRecords(
deployments: Deployment[]
): Promise<AppDeploymentRecord[]> {
// Fetch ApplicationDeploymentRecords for corresponding ApplicationRecord set in deployments
// TODO: Implement Laconicd GQL query to filter records by multiple values for an attribute
const records = await this.registry.queryRecords(
{
type: APP_DEPLOYMENT_RECORD_TYPE
},
true
)
// Filter records with ApplicationDeploymentRequestId ID
return records.filter((record: AppDeploymentRecord) =>
deployments.some(
(deployment) =>
deployment.applicationDeploymentRequestId ===
record.attributes.request
)
)
}
/**
* Fetch WebappDeployer Records by filter
*/
async getDeployerRecordsByFilter(filter: { [key: string]: any }): Promise<
DeployerRecord[]
> {
return this.registry.queryRecords(
{
type: WEBAPP_DEPLOYER_RECORD_TYPE,
...filter
},
true
)
}
/**
* Fetch ApplicationDeploymentRecords by filter
*/
async getDeploymentRecordsByFilter(filter: { [key: string]: any }): Promise<
AppDeploymentRecord[]
> {
return this.registry.queryRecords(
{
type: APP_DEPLOYMENT_RECORD_TYPE,
...filter
},
true
)
}
/**
* Fetch ApplicationDeploymentRemovalRecords for deployments
*/
async getDeploymentRemovalRecords(
deployments: Deployment[]
): Promise<AppDeploymentRemovalRecord[]> {
// Fetch ApplicationDeploymentRemovalRecords for corresponding ApplicationDeploymentRecord set in deployments
const records = await this.registry.queryRecords(
{
type: APP_DEPLOYMENT_REMOVAL_RECORD_TYPE
},
true
)
// Filter records with ApplicationDeploymentRecord and ApplicationDeploymentRemovalRequest IDs
return records.filter((record: AppDeploymentRemovalRecord) =>
deployments.some(
(deployment) =>
deployment.applicationDeploymentRemovalRequestId ===
record.attributes.request &&
deployment.applicationDeploymentRecordId ===
record.attributes.deployment
)
)
}
/**
* Fetch record by Id
*/
async getRecordById(id: string): Promise<RegistryRecord | null> {
const [record] = await this.registry.getRecordsByIds([id])
return record ?? null
}
async createApplicationDeploymentRemovalRequest(data: {
deploymentId: string
deployerLrn: string
auctionId?: string | null
payment?: string | null
}): Promise<{
applicationDeploymentRemovalRequestId: string
applicationDeploymentRemovalRequestData: ApplicationDeploymentRemovalRequest
}> {
const applicationDeploymentRemovalRequest = {
type: APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE,
version: '1.0.0',
deployment: data.deploymentId,
deployer: data.deployerLrn,
...(data.auctionId && { auction: data.auctionId }),
...(data.payment && { payment: data.payment })
}
const result = await this.publishRecord(applicationDeploymentRemovalRequest)
log(`Application deployment removal request record published: ${result.id}`)
log(
'Application deployment removal request data:',
applicationDeploymentRemovalRequest
)
return {
applicationDeploymentRemovalRequestId: result.id,
applicationDeploymentRemovalRequestData:
applicationDeploymentRemovalRequest
}
}
async getCompletedAuctionIds(auctionIds: string[]): Promise<string[]> {
if (auctionIds.length === 0) {
return []
}
const auctions = await this.registry.getAuctionsByIds(auctionIds)
const completedAuctions = auctions
.filter(
(auction: { id: string; status: string }) =>
auction.status === 'completed'
)
.map((auction: { id: string; status: string }) => auction.id)
return completedAuctions
}
async publishRecord(recordData: any): Promise<any> {
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees
)
const result = await registryTransactionWithRetry(() =>
this.registry.setRecord(
{
privateKey: this.registryConfig.privateKey,
record: recordData,
bondId: this.registryConfig.bondId
},
this.registryConfig.privateKey,
fee
)
)
return result
}
async getRecordsByName(name: string): Promise<any> {
return this.registry.resolveNames([name])
}
async getAuctionData(auctionId: string): Promise<any> {
return this.registry.getAuctionsByIds([auctionId])
}
async sendTokensToAccount(
receiverAddress: string,
amount: string
): Promise<DeliverTxResponse> {
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees
)
const account = await this.getAccount()
const laconicClient = await this.registry.getLaconicClient(account)
const txResponse: DeliverTxResponse = await registryTransactionWithRetry(
() =>
laconicClient.sendTokens(
account.address,
receiverAddress,
[
{
denom: 'alnt',
amount
}
],
fee || DEFAULT_GAS_ESTIMATION_MULTIPLIER
)
)
return txResponse
}
async getAccount(): Promise<Account> {
const account = new Account(
Buffer.from(this.registryConfig.privateKey, 'hex')
)
await account.init()
return account
}
async getTxResponse(txHash: string): Promise<IndexedTx | null> {
const account = await this.getAccount()
const laconicClient = await this.registry.getLaconicClient(account)
const txResponse: IndexedTx | null = await laconicClient.getTx(txHash)
return txResponse
}
getLrn(appName: string): string {
assert(this.registryConfig.authority, "Authority doesn't exist")
return `lrn://${this.registryConfig.authority}/applications/${appName}`
}
async generateConfigHash(
environmentVariables: { [key: string]: string },
requesterAddress: string,
pubKey: string,
url: string
): Promise<string> {
// Config to be encrypted
const config = {
authorized: [requesterAddress],
config: { env: environmentVariables }
}
// Serialize the config
const serialized = JSON.stringify(config, null, 2)
const armoredKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n${pubKey}\n\n-----END PGP PUBLIC KEY BLOCK-----`
const publicKey = await openpgp.readKey({ armoredKey })
// Encrypt the config
const encrypted = await openpgp.encrypt({
message: await openpgp.createMessage({ text: serialized }),
encryptionKeys: publicKey,
format: 'binary'
})
// Get the hash after uploading encrypted config
const response = await fetch(`${url}/upload/config`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: encrypted
})
const configHash = await response.json()
return configHash.id
}
}
+413
View File
@@ -0,0 +1,413 @@
import debug from 'debug'
import type { DeepPartial, FindOptionsWhere } from 'typeorm'
import type { Domain } from './entity/Domain'
import type { EnvironmentVariable } from './entity/EnvironmentVariable'
import type { Project } from './entity/Project'
import type { Permission } from './entity/ProjectMember'
import type { Service } from './service'
import type {
AddProjectFromTemplateInput,
AuctionParams,
EnvironmentVariables
} from './types'
const log = debug('snowball:resolver')
export const createResolvers = async (service: Service): Promise<any> => {
return {
Query: {
// TODO: add custom type for context
user: (_: any, __: any, context: any) => {
return context.user
},
organizations: async (_: any, __: any, context: any) => {
return service.getOrganizationsByUserId(context.user)
},
project: async (
_: any,
{ projectId }: { projectId: string },
context: any
) => {
return service.getProjectById(context.user, projectId)
},
projectsInOrganization: async (
_: any,
{ organizationSlug }: { organizationSlug: string },
context: any
) => {
return service.getProjectsInOrganization(context.user, organizationSlug)
},
deployments: async (_: any, { projectId }: { projectId: string }) => {
return service.getNonCanonicalDeploymentsByProjectId(projectId)
},
environmentVariables: async (
_: any,
{ projectId }: { projectId: string }
) => {
return service.getEnvironmentVariablesByProjectId(projectId)
},
projectMembers: async (_: any, { projectId }: { projectId: string }) => {
return service.getProjectMembersByProjectId(projectId)
},
searchProjects: async (
_: any,
{ searchText }: { searchText: string },
context: any
) => {
return service.searchProjects(context.user, searchText)
},
domains: async (
_: any,
{
projectId,
filter
}: { projectId: string; filter?: FindOptionsWhere<Domain> }
) => {
return service.getDomainsByProjectId(projectId, filter)
},
getAuctionData: async (_: any, { auctionId }: { auctionId: string }) => {
return service.getAuctionData(auctionId)
},
deployers: async (_: any, __: any) => {
return service.getDeployers()
},
address: async (_: any, __: any) => {
return service.getAddress()
},
verifyTx: async (
_: any,
{
txHash,
amount,
senderAddress
}: { txHash: string; amount: string; senderAddress: string }
) => {
return service.verifyTx(txHash, amount, senderAddress)
},
latestDNSRecord: async (_: any, { projectId }: { projectId: string }) => {
return service.getLatestDNSRecordByProjectId(projectId)
}
},
// TODO: Return error in GQL response
Mutation: {
removeProjectMember: async (
_: any,
{ projectMemberId }: { projectMemberId: string },
context: any
) => {
try {
return await service.removeProjectMember(
context.user,
projectMemberId
)
} catch (err) {
log(err)
return false
}
},
updateProjectMember: async (
_: any,
{
projectMemberId,
data
}: {
projectMemberId: string
data: {
permissions: Permission[]
}
}
) => {
try {
return await service.updateProjectMember(projectMemberId, data)
} catch (err) {
log(err)
return false
}
},
addProjectMember: async (
_: any,
{
projectId,
data
}: {
projectId: string
data: {
email: string
permissions: Permission[]
}
}
) => {
try {
return Boolean(await service.addProjectMember(projectId, data))
} catch (err) {
log(err)
return false
}
},
addEnvironmentVariables: async (
_: any,
{
projectId,
data
}: {
projectId: string
data: { environments: string[]; key: string; value: string }[]
}
) => {
try {
return Boolean(await service.addEnvironmentVariables(projectId, data))
} catch (err) {
log(err)
return false
}
},
updateEnvironmentVariable: async (
_: any,
{
environmentVariableId,
data
}: {
environmentVariableId: string
data: DeepPartial<EnvironmentVariable>
}
) => {
try {
return await service.updateEnvironmentVariable(
environmentVariableId,
data
)
} catch (err) {
log(err)
return false
}
},
removeEnvironmentVariable: async (
_: any,
{ environmentVariableId }: { environmentVariableId: string }
) => {
try {
return await service.removeEnvironmentVariable(environmentVariableId)
} catch (err) {
log(err)
return false
}
},
updateDeploymentToProd: async (
_: any,
{ deploymentId }: { deploymentId: string },
context: any
) => {
try {
return Boolean(
await service.updateDeploymentToProd(context.user, deploymentId)
)
} catch (err) {
log(err)
return false
}
},
addProjectFromTemplate: async (
_: any,
{
organizationSlug,
data,
lrn,
auctionParams,
environmentVariables
}: {
organizationSlug: string
data: AddProjectFromTemplateInput
lrn: string
auctionParams: AuctionParams
environmentVariables: EnvironmentVariables[]
},
context: any
) => {
try {
return await service.addProjectFromTemplate(
context.user,
organizationSlug,
data,
lrn,
auctionParams,
environmentVariables
)
} catch (err) {
log(err)
throw err
}
},
addProject: async (
_: any,
{
organizationSlug,
data,
lrn,
auctionParams,
environmentVariables
}: {
organizationSlug: string
data: DeepPartial<Project>
lrn: string
auctionParams: AuctionParams
environmentVariables: EnvironmentVariables[]
},
context: any
) => {
try {
return await service.addProject(
context.user,
organizationSlug,
data,
lrn,
auctionParams,
environmentVariables
)
} catch (err) {
log(err)
throw err
}
},
updateProject: async (
_: any,
{ projectId, data }: { projectId: string; data: DeepPartial<Project> }
) => {
try {
return await service.updateProject(projectId, data)
} catch (err) {
log(err)
return false
}
},
redeployToProd: async (
_: any,
{ deploymentId }: { deploymentId: string },
context: any
) => {
try {
return Boolean(
await service.redeployToProd(context.user, deploymentId)
)
} catch (err) {
log(err)
return false
}
},
deleteProject: async (_: any, { projectId }: { projectId: string }) => {
try {
return await service.deleteProject(projectId)
} catch (err) {
log(err)
return false
}
},
deleteDomain: async (_: any, { domainId }: { domainId: string }) => {
try {
return await service.deleteDomain(domainId)
} catch (err) {
log(err)
return false
}
},
rollbackDeployment: async (
_: any,
{ projectId, deploymentId }: { deploymentId: string; projectId: string }
) => {
try {
return await service.rollbackDeployment(projectId, deploymentId)
} catch (err) {
log(err)
return false
}
},
deleteDeployment: async (
_: any,
{ deploymentId }: { deploymentId: string }
) => {
try {
return await service.deleteDeployment(deploymentId)
} catch (err) {
log(err)
return false
}
},
addDomain: async (
_: any,
{ projectId, data }: { projectId: string; data: { name: string } }
) => {
try {
return Boolean(await service.addDomain(projectId, data))
} catch (err) {
log(err)
return false
}
},
updateDomain: async (
_: any,
{ domainId, data }: { domainId: string; data: DeepPartial<Domain> }
) => {
try {
return await service.updateDomain(domainId, data)
} catch (err) {
log(err)
return false
}
},
authenticateGitHub: async (
_: any,
{ code }: { code: string },
context: any
) => {
try {
return await service.authenticateGitHub(code, context.user)
} catch (err) {
log(err)
return false
}
},
unauthenticateGitHub: async (_: any, __: object, context: any) => {
try {
return service.unauthenticateGitHub(context.user, {
gitHubToken: null
})
} catch (err) {
log(err)
return false
}
}
}
}
}
+97
View File
@@ -0,0 +1,97 @@
import { Router } from 'express'
import { SiweMessage } from 'siwe'
import type { Service } from '../service'
import { authenticateUser, createUser } from '../turnkey-backend'
const router: Router = Router()
//
// Turnkey
//
router.get('/registration/:email', async (req, res) => {
const service: Service = req.app.get('service')
const user = await service.getUserByEmail(req.params.email)
if (user) {
return res.send({ subOrganizationId: user?.subOrgId })
}
return res.sendStatus(204)
})
router.post('/register', async (req, res) => {
console.log('Register', req.body)
const { email, challenge, attestation } = req.body
const user = await createUser(req.app.get('service'), {
challenge,
attestation,
userEmail: email,
userName: email.split('@')[0]
})
req.session.address = user.id
res.sendStatus(200)
})
router.post('/authenticate', async (req, res) => {
console.log('Authenticate', req.body)
const { signedWhoamiRequest } = req.body
const user = await authenticateUser(
req.app.get('service'),
signedWhoamiRequest
)
if (user) {
req.session.address = user.id
res.sendStatus(200)
} else {
res.sendStatus(401)
}
})
//
// SIWE Auth
//
router.post('/validate', async (req, res) => {
const { message, signature } = req.body
const { success, data } = await new SiweMessage(message).verify({
signature
})
if (!success) {
return res.send({ success })
}
const service: Service = req.app.get('service')
const user = await service.getUserByEthAddress(data.address)
if (!user) {
const newUser = await service.createUser({
ethAddress: data.address,
email: `${data.address}@example.com`,
subOrgId: '',
turnkeyWalletId: ''
})
// SIWESession from the web3modal library requires both address and chain ID
req.session.address = newUser.id
req.session.chainId = data.chainId
} else {
req.session.address = user.id
req.session.chainId = data.chainId
}
res.send({ success })
})
//
// General
//
router.get('/session', (req, res) => {
if (req.session.address && req.session.chainId) {
res.send({
address: req.session.address,
chainId: req.session.chainId
})
} else {
res.status(401).send({ error: 'Unauthorized: No active session' })
}
})
export default router
+26
View File
@@ -0,0 +1,26 @@
import debug from 'debug'
import { Router } from 'express'
import type { Service } from '../service'
const log = debug('snowball:routes-github')
const router: Router = Router()
/* POST GitHub webhook handler */
// https://docs.github.com/en/webhooks/using-webhooks/handling-webhook-deliveries#javascript-example
router.post('/webhook', async (req, res) => {
// Server should respond with a 2XX response within 10 seconds of receiving a webhook delivery
// If server takes longer than that to respond, then GitHub terminates the connection and considers the delivery a failure
res.status(202).send('Accepted')
const service = req.app.get('service') as Service
const githubEvent = req.headers['x-github-event']
log(`Received GitHub webhook for event ${githubEvent}`)
if (githubEvent === 'push') {
// Create deployments using push event data
await service.handleGitHubPush(req.body)
}
})
export default router
+9
View File
@@ -0,0 +1,9 @@
import { Router } from 'express'
const router: Router = Router()
router.get('/version', async (_req, res) => {
return res.send({ version: '0.0.9' })
})
export default router
+337
View File
@@ -0,0 +1,337 @@
enum Role {
Owner
Maintainer
Reader
}
enum Permission {
View
Edit
}
enum Environment {
Production
Preview
Development
}
enum DeploymentStatus {
Building
Ready
Error
Deleting
}
enum AuctionStatus {
completed
reveal
commit
expired
}
enum DomainStatus {
Live
Pending
}
type User {
id: String!
name: String
email: String!
organizations: [Organization!]
projects: [Project!]
isVerified: Boolean!
createdAt: String!
updatedAt: String!
gitHubToken: String
}
type Organization {
id: String!
name: String!
slug: String!
projects: [Project!]
createdAt: String!
updatedAt: String!
members: [OrganizationMember!]
}
type OrganizationMember {
id: String!
member: User!
role: Role!
createdAt: String!
updatedAt: String!
}
type Project {
id: String!
owner: User!
deployments: [Deployment!]
name: String!
repository: String!
prodBranch: String!
description: String
deployers: [Deployer!]
auctionId: String
fundsReleased: Boolean
template: String
framework: String
paymentAddress: String!
txHash: String!
webhooks: [String!]
members: [ProjectMember!]
environmentVariables: [EnvironmentVariable!]
createdAt: String!
updatedAt: String!
organization: Organization!
icon: String
baseDomains: [String!]
}
type ProjectMember {
id: String!
member: User!
permissions: [Permission!]!
isPending: Boolean!
createdAt: String!
updatedAt: String!
}
type Deployment {
id: String!
branch: String!
commitHash: String!
commitMessage: String!
url: String
environment: Environment!
deployer: Deployer
applicationDeploymentRequestId: String
applicationDeploymentRecordData: AppDeploymentRecordAttributes
isCurrent: Boolean!
baseDomain: String
status: DeploymentStatus!
createdAt: String!
updatedAt: String!
createdBy: User!
}
type Domain {
id: String!
branch: String!
name: String!
redirectTo: Domain
status: DomainStatus!
createdAt: String!
updatedAt: String!
}
type EnvironmentVariable {
id: String!
environment: Environment!
key: String!
value: String!
createdAt: String!
updatedAt: String!
}
type Deployer {
deployerLrn: String!
deployerId: String!
deployerApiUrl: String!
minimumPayment: String
paymentAddress: String
createdAt: String!
updatedAt: String!
baseDomain: String
}
type AuthResult {
token: String!
}
input AddEnvironmentVariableInput {
environments: [Environment!]!
key: String!
value: String!
}
input AddProjectFromTemplateInput {
templateOwner: String!
templateRepo: String!
owner: String!
name: String!
isPrivate: Boolean!
paymentAddress: String!
txHash: String!
}
input AddProjectInput {
name: String!
repository: String!
prodBranch: String!
template: String
paymentAddress: String!
txHash: String!
}
input UpdateProjectInput {
name: String
description: String
prodBranch: String
organizationId: String
webhooks: [String!]
}
input AddDomainInput {
name: String!
}
input UpdateDomainInput {
name: String
branch: String
redirectToId: String
}
input UpdateEnvironmentVariableInput {
key: String
value: String
}
input AddProjectMemberInput {
email: String!
permissions: [Permission!]
}
input UpdateProjectMemberInput {
permissions: [Permission]
}
input FilterDomainsInput {
branch: String
status: DomainStatus
}
type Fee {
type: String!
quantity: String!
}
type Bid {
auctionId: String!
bidderAddress: String!
status: String!
commitHash: String!
commitTime: String
commitFee: Fee
revealTime: String
revealFee: Fee
bidAmount: Fee
}
type Auction {
id: String!
kind: String!
status: String!
ownerAddress: String!
createTime: String!
commitsEndTime: String!
revealsEndTime: String!
commitFee: Fee!
revealFee: Fee!
minimumBid: Fee
winnerAddresses: [String!]!
winnerBids: [Fee!]
winnerPrice: Fee
maxPrice: Fee
numProviders: Int!
fundsReleased: Boolean!
bids: [Bid!]!
}
type DNSRecordAttributes {
name: String
value: String
request: String
resourceType: String
version: String
}
type AppDeploymentRecordAttributes {
application: String
auction: String
deployer: String
dns: String
meta: String
name: String
request: String
type: String
url: String
version: String
}
input AuctionParams {
maxPrice: String
numProviders: Int
}
type Query {
user: User!
organizations: [Organization!]
projects: [Project!]
projectsInOrganization(organizationSlug: String!): [Project!]
project(projectId: String!): Project
deployments(projectId: String!): [Deployment!]
environmentVariables(projectId: String!): [EnvironmentVariable!]
projectMembers(projectId: String!): [ProjectMember!]
searchProjects(searchText: String!): [Project!]
getAuctionData(auctionId: String!): Auction!
latestDNSRecord(projectId: String!): DNSRecordAttributes
domains(projectId: String!, filter: FilterDomainsInput): [Domain]
deployers: [Deployer]
address: String!
verifyTx(txHash: String!, amount: String!, senderAddress: String!): Boolean!
}
type Mutation {
addProjectMember(projectId: String!, data: AddProjectMemberInput): Boolean!
updateProjectMember(
projectMemberId: String!
data: UpdateProjectMemberInput
): Boolean!
removeProjectMember(projectMemberId: String!): Boolean!
addEnvironmentVariables(
projectId: String!
data: [AddEnvironmentVariableInput!]
): Boolean!
updateEnvironmentVariable(
environmentVariableId: String!
data: UpdateEnvironmentVariableInput!
): Boolean!
removeEnvironmentVariable(environmentVariableId: String!): Boolean!
updateDeploymentToProd(deploymentId: String!): Boolean!
addProjectFromTemplate(
organizationSlug: String!
data: AddProjectFromTemplateInput
lrn: String
auctionParams: AuctionParams
environmentVariables: [AddEnvironmentVariableInput!]
): Project!
addProject(
organizationSlug: String!
data: AddProjectInput!
lrn: String
auctionParams: AuctionParams
environmentVariables: [AddEnvironmentVariableInput!]
): Project!
updateProject(projectId: String!, data: UpdateProjectInput): Boolean!
redeployToProd(deploymentId: String!): Boolean!
deleteProject(projectId: String!): Boolean!
deleteDomain(domainId: String!): Boolean!
rollbackDeployment(projectId: String!, deploymentId: String!): Boolean!
deleteDeployment(deploymentId: String!): Boolean!
addDomain(projectId: String!, data: AddDomainInput!): Boolean!
updateDomain(domainId: String!, data: UpdateDomainInput!): Boolean!
authenticateGitHub(code: String!): AuthResult!
unauthenticateGitHub: Boolean!
}
+130
View File
@@ -0,0 +1,130 @@
import {
ApolloServerPluginDrainHttpServer,
ApolloServerPluginLandingPageLocalDefault,
AuthenticationError
} from 'apollo-server-core'
import { ApolloServer } from 'apollo-server-express'
import cors from 'cors'
import debug from 'debug'
import express from 'express'
import session from 'express-session'
import { createServer } from 'node:http'
import { makeExecutableSchema } from '@graphql-tools/schema'
import type { TypeSource } from '@graphql-tools/utils'
import type { ServerConfig } from './config'
import authRouter from './routes/auth'
import githubRouter from './routes/github'
import stagingRouter from './routes/staging'
import type { Service } from './service'
const log = debug('snowball:server')
// Set cookie expiration to 1 month in milliseconds
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000
declare module 'express-session' {
interface SessionData {
address: string
chainId: number
}
}
export const createAndStartServer = async (
serverConfig: ServerConfig,
typeDefs: TypeSource,
resolvers: any,
service: Service
): Promise<ApolloServer> => {
const { host, port, gqlPath = '/graphql' } = serverConfig
const { appOriginUrl, secret, domain, trustProxy } = serverConfig.session
const app = express()
// Create HTTP server
const httpServer = createServer(app)
// Create the schema
const schema = makeExecutableSchema({
typeDefs,
resolvers
})
const server = new ApolloServer({
schema,
csrfPrevention: true,
context: async ({ req }) => {
// https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization
const { address } = req.session
if (!address) {
throw new AuthenticationError('Unauthorized: No active session')
}
const user = await service.getUser(address)
return { user }
},
plugins: [
// Proper shutdown for the HTTP server
ApolloServerPluginDrainHttpServer({ httpServer }),
ApolloServerPluginLandingPageLocalDefault({ embed: true })
]
})
await server.start()
app.use(
cors({
origin: appOriginUrl,
credentials: true
})
)
const sessionOptions: session.SessionOptions = {
secret: secret,
resave: false,
saveUninitialized: true,
cookie: {
secure: new URL(appOriginUrl).protocol === 'https:',
maxAge: COOKIE_MAX_AGE,
domain: domain || undefined,
sameSite: new URL(appOriginUrl).protocol === 'https:' ? 'none' : 'lax'
}
}
if (trustProxy) {
// trust first proxy
app.set('trust proxy', 1)
}
app.use(session(sessionOptions))
server.applyMiddleware({
app: app as any,
path: gqlPath,
cors: {
origin: [appOriginUrl],
credentials: true
}
})
app.use(express.json())
app.set('service', service)
app.use('/auth', authRouter)
app.use('/api/github', githubRouter)
app.use('/staging', stagingRouter)
app.use((err: any, _req: any, res: any, _next: any) => {
console.error(err)
res.status(500).json({ error: err.message })
})
httpServer.listen(port, host, () => {
log(`Server is listening on ${host}:${port}${server.graphqlPath}`)
})
return server
}
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
import { Turnkey, type TurnkeyApiTypes } from '@turnkey/sdk-server'
// Default path for the first Ethereum address in a new HD wallet.
// See https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki, paths are in the form:
// m / purpose' / coin_type' / account' / change / address_index
// - Purpose is a constant set to 44' following the BIP43 recommendation.
// - Coin type is set to 60 (ETH) -- see https://github.com/satoshilabs/slips/blob/master/slip-0044.md
// - Account, Change, and Address Index are set to 0
import { DEFAULT_ETHEREUM_ACCOUNTS } from '@turnkey/sdk-server'
import type { Service } from './service'
import { getConfig } from './utils'
type TAttestation = TurnkeyApiTypes['v1Attestation']
type CreateUserParams = {
userName: string
userEmail: string
challenge: string
attestation: TAttestation
}
export async function createUser(
service: Service,
{ userName, userEmail, challenge, attestation }: CreateUserParams
) {
try {
if (await service.getUserByEmail(userEmail)) {
throw new Error(`User already exists: ${userEmail}`)
}
const config = await getConfig()
const turnkey = new Turnkey(config.turnkey)
const apiClient = turnkey.api()
const walletName = 'Default ETH Wallet'
const createSubOrgResponse = await apiClient.createSubOrganization({
subOrganizationName: `Default SubOrg for ${userEmail}`,
rootQuorumThreshold: 1,
rootUsers: [
{
userName,
userEmail,
apiKeys: [],
authenticators: [
{
authenticatorName: 'Passkey',
challenge,
attestation
}
]
}
],
wallet: {
walletName: walletName,
accounts: DEFAULT_ETHEREUM_ACCOUNTS
}
})
const subOrgId = refineNonNull(createSubOrgResponse.subOrganizationId)
const wallet = refineNonNull(createSubOrgResponse.wallet)
const result = {
id: wallet.walletId,
address: wallet.addresses[0],
subOrgId: subOrgId
}
console.log('Turnkey success', result)
const user = await service.createUser({
name: userName,
email: userEmail,
subOrgId,
ethAddress: wallet.addresses[0],
turnkeyWalletId: wallet.walletId
})
console.log('New user', user)
return user
} catch (e) {
console.error('Failed to create user:', e)
throw e
}
}
export async function authenticateUser(
service: Service,
signedWhoamiRequest: {
url: string
body: any
stamp: {
stampHeaderName: string
stampHeaderValue: string
}
}
) {
try {
const tkRes = await fetch(signedWhoamiRequest.url, {
method: 'POST',
body: signedWhoamiRequest.body,
headers: {
[signedWhoamiRequest.stamp.stampHeaderName]:
signedWhoamiRequest.stamp.stampHeaderValue
}
})
console.log('AUTH RESULT', tkRes.status)
if (tkRes.status !== 200) {
console.log(await tkRes.text())
return null
}
const orgId = (await tkRes.json()).organizationId
const user = await service.getUserBySubOrgId(orgId)
return user
} catch (e) {
console.error('Failed to authenticate:', e)
throw e
}
}
function refineNonNull<T>(
input: T | null | undefined,
errorMessage?: string
): T {
if (input == null) {
throw new Error(errorMessage ?? `Unexpected ${JSON.stringify(input)}`)
}
return input
}
+124
View File
@@ -0,0 +1,124 @@
export interface PackageJSON {
name: string
version: string
author?: string
description?: string
homepage?: string
license?: string
repository?: string
}
export interface GitRepositoryDetails {
id: number
name: string
full_name: string
visibility?: string
updated_at?: string | null
default_branch?: string
}
export interface GitPushEventPayload {
repository: GitRepositoryDetails
ref: string
head_commit: {
id: string
message: string
}
deleted: boolean
}
export interface AppDeploymentRecordAttributes {
application: string
auction: string
deployer: string
dns: string
meta: string
name: string
request: string
type: string
url: string
version: string
}
export interface DNSRecordAttributes {
name: string
value: string
request: string
resourceType: string
version: string
}
export interface RegistryDNSRecordAttributes {
name: string
value: string
request: string
resource_type: string
version: string
}
export interface AppDeploymentRemovalRecordAttributes {
deployment: string
request: string
type: 'ApplicationDeploymentRemovalRecord'
version: string
}
export interface RegistryRecord {
id: string
names: string[] | null
owners: string[]
bondId: string
createTime: string
expiryTime: string
}
export interface AppDeploymentRecord extends RegistryRecord {
attributes: AppDeploymentRecordAttributes
}
export interface AppDeploymentRemovalRecord extends RegistryRecord {
attributes: AppDeploymentRemovalRecordAttributes
}
export interface DNSRecord extends RegistryRecord {
attributes: RegistryDNSRecordAttributes
}
export interface AddProjectFromTemplateInput {
templateOwner: string
templateRepo: string
owner: string
name: string
isPrivate: boolean
paymentAddress: string
txHash: string
}
export interface AuctionParams {
maxPrice: string
numProviders: number
}
export interface EnvironmentVariables {
environments: string[]
key: string
value: string
}
export interface DeployerRecord {
id: string
names: string[]
owners: string[]
bondId: string
createTime: string
expiryTime: string
attributes: {
apiUrl: string
minimumPayment: string | null
name: string
paymentAddress: string
publicKey: string
type: string
version: string
}
}
+160
View File
@@ -0,0 +1,160 @@
import debug from 'debug'
import fs from 'fs-extra'
import assert from 'node:assert'
import path from 'node:path'
import type { Octokit } from 'octokit'
import toml from 'toml'
import type {
DataSource,
DeepPartial,
EntityTarget,
ObjectLiteral
} from 'typeorm'
import type { Config } from './config'
interface PackageJSON {
name: string
description?: string
homepage?: string
license?: string
author?: string | { [key: string]: unknown }
version?: string
[key: string]: unknown
}
const log = debug('snowball:utils')
export async function getConfig() {
return await _getConfig<Config>(
path.join(__dirname, '../environments/local.toml')
)
}
const _getConfig = async <ConfigType>(
configFile: string
): Promise<ConfigType> => {
const fileExists = await fs.pathExists(configFile)
if (!fileExists) {
throw new Error(`Config file not found: ${configFile}`)
}
const config = toml.parse(await fs.readFile(configFile, 'utf8'))
log('config', JSON.stringify(config, null, 2))
return config
}
export const checkFileExists = async (filePath: string): Promise<boolean> => {
try {
await fs.access(filePath, fs.constants.F_OK)
return true
} catch (err) {
log(err)
return false
}
}
export const getEntities = async (filePath: string): Promise<any> => {
const entitiesData = await fs.readFile(filePath, 'utf-8')
const entities = JSON.parse(entitiesData)
return entities
}
export const loadAndSaveData = async <Entity extends ObjectLiteral>(
entityType: EntityTarget<Entity>,
dataSource: DataSource,
entities: any,
relations?: any | undefined
): Promise<Entity[]> => {
const entityRepository = dataSource.getRepository(entityType)
const savedEntity: Entity[] = []
for (const entityData of entities) {
let entity = entityRepository.create(entityData as DeepPartial<Entity>)
if (relations) {
for (const field in relations) {
const valueIndex = `${field}Index`
entity = {
...entity,
[field]: relations[field][entityData[valueIndex]]
}
}
}
const dbEntity = await entityRepository.save(entity)
savedEntity.push(dbEntity)
}
return savedEntity
}
export const sleep = async (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms))
export const getRepoDetails = async (
octokit: Octokit,
repository: string,
commitHash: string | undefined
): Promise<{
repo: string
packageJSON: PackageJSON
repoUrl: string
}> => {
const [owner, repo] = repository.split('/')
const { data: packageJSONData } = await octokit.rest.repos.getContent({
owner,
repo,
path: 'package.json',
ref: commitHash
})
if (!packageJSONData) {
throw new Error('Package.json file not found')
}
assert(!Array.isArray(packageJSONData) && packageJSONData.type === 'file')
const packageJSON: PackageJSON = JSON.parse(atob(packageJSONData.content))
assert(packageJSON.name, "name field doesn't exist in package.json")
const repoUrl = (
await octokit.rest.repos.get({
owner,
repo
})
).data.html_url
return {
repo,
packageJSON,
repoUrl
}
}
// Wrapper method for registry txs to retry once if 'account sequence mismatch' occurs
export const registryTransactionWithRetry = async (
txMethod: () => Promise<any>
): Promise<any> => {
try {
return await txMethod()
} catch (error: any) {
if (!error.message.includes('account sequence mismatch')) {
throw error
}
console.error(
'Transaction failed due to account sequence mismatch. Retrying...'
)
try {
return await txMethod()
} catch (retryError: any) {
throw new Error(
`Transaction failed again after retry: ${retryError.message}`
)
}
}
}
+19
View File
@@ -0,0 +1,19 @@
import * as fs from 'node:fs/promises'
import debug from 'debug'
import { getConfig } from '../src/utils'
const log = debug('snowball:delete-database')
const deleteFile = async (filePath: string) => {
await fs.unlink(filePath)
log(`File ${filePath} has been deleted.`)
}
const main = async () => {
const config = await getConfig()
deleteFile(config.database.dbPath)
}
main().catch((err) => log(err))
+189
View File
@@ -0,0 +1,189 @@
[
{
"projectIndex": 0,
"domainIndex": 0,
"createdByIndex": 0,
"id": "ffhae3zq",
"status": "Ready",
"environment": "Production",
"isCurrent": true,
"applicationRecordId": "qbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "xqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "main",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-ffhae3zq.snowball.xyz"
},
{
"projectIndex": 0,
"domainIndex": 1,
"createdByIndex": 0,
"id": "vehagei8",
"status": "Ready",
"environment": "Preview",
"isCurrent": false,
"applicationRecordId": "wbafyreihvzya6ovp4yfpkqnddkui2iw7thbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "wqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-vehagei8.snowball.xyz"
},
{
"projectIndex": 0,
"domainIndex": 2,
"createdByIndex": 0,
"id": "qmgekyte",
"status": "Ready",
"environment": "Development",
"isCurrent": false,
"applicationRecordId": "ebafyreihvzya6ovp4yfpkqnddkui2iw7t6bhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "kqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-qmgekyte.snowball.xyz"
},
{
"projectIndex": 0,
"domainIndex": null,
"createdByIndex": 0,
"id": "f8wsyim6",
"status": "Ready",
"environment": "Production",
"isCurrent": false,
"applicationRecordId": "rbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhw74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "yqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "prod",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-f8wsyim6.snowball.xyz"
},
{
"projectIndex": 1,
"domainIndex": 3,
"createdByIndex": 1,
"id": "eO8cckxk",
"status": "Ready",
"environment": "Production",
"isCurrent": true,
"applicationRecordId": "tbafyreihvzya6ovp4yfpqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "pqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "main",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-2-eO8cckxk.snowball.xyz"
},
{
"projectIndex": 1,
"domainIndex": 4,
"createdByIndex": 1,
"id": "yaq0t5yw",
"status": "Ready",
"environment": "Preview",
"isCurrent": false,
"applicationRecordId": "ybafyreihvzya6ovp4yfpkqnddkui2iw7t6bhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "tqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-2-yaq0t5yw.snowball.xyz"
},
{
"projectIndex": 1,
"domainIndex": 5,
"createdByIndex": 1,
"id": "hwwr6sbx",
"status": "Ready",
"environment": "Development",
"isCurrent": false,
"applicationRecordId": "ubafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "eqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-2-hwwr6sbx.snowball.xyz"
},
{
"projectIndex": 2,
"domainIndex": 9,
"createdByIndex": 2,
"id": "ndxje48a",
"status": "Ready",
"environment": "Production",
"isCurrent": true,
"applicationRecordId": "ibayreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "dqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "main",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "iglootools-ndxje48a.snowball.xyz"
},
{
"projectIndex": 2,
"domainIndex": 7,
"createdByIndex": 2,
"id": "gtgpgvei",
"status": "Ready",
"environment": "Preview",
"isCurrent": false,
"applicationRecordId": "obafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "aqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "iglootools-gtgpgvei.snowball.xyz"
},
{
"projectIndex": 2,
"domainIndex": 8,
"createdByIndex": 2,
"id": "b4bpthjr",
"status": "Ready",
"environment": "Development",
"isCurrent": false,
"applicationRecordId": "pbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowo",
"applicationRecordData": {},
"applicationDeploymentRequestId": "uqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "iglootools-b4bpthjr.snowball.xyz"
},
{
"projectIndex": 3,
"domainIndex": 6,
"createdByIndex": 2,
"id": "b4bpthjr",
"status": "Ready",
"environment": "Production",
"isCurrent": true,
"applicationRecordId": "pbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowo",
"applicationRecordData": {},
"applicationDeploymentRequestId": "pqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "iglootools-b4bpthjr.snowball.xyz"
}
]
+92
View File
@@ -0,0 +1,92 @@
[
{
"projectIndex": 0,
"key": "ABC",
"value": "ABC",
"environment": "Production"
},
{
"projectIndex": 0,
"key": "ABC",
"value": "ABC",
"environment": "Preview"
},
{
"projectIndex": 0,
"key": "XYZ",
"value": "abc3",
"environment": "Preview"
},
{
"projectIndex": 1,
"key": "ABC",
"value": "ABC",
"environment": "Production"
},
{
"projectIndex": 1,
"key": "ABC",
"value": "ABC",
"environment": "Preview"
},
{
"projectIndex": 1,
"key": "XYZ",
"value": "abc3",
"environment": "Preview"
},
{
"projectIndex": 2,
"key": "ABC",
"value": "ABC",
"environment": "Production"
},
{
"projectIndex": 2,
"key": "ABC",
"value": "ABC",
"environment": "Preview"
},
{
"projectIndex": 2,
"key": "XYZ",
"value": "abc3",
"environment": "Preview"
},
{
"projectIndex": 3,
"key": "ABC",
"value": "ABC",
"environment": "Production"
},
{
"projectIndex": 3,
"key": "ABC",
"value": "ABC",
"environment": "Preview"
},
{
"projectIndex": 3,
"key": "XYZ",
"value": "abc3",
"environment": "Preview"
},
{
"projectIndex": 4,
"key": "ABC",
"value": "ABC",
"environment": "Production"
},
{
"projectIndex": 4,
"key": "ABC",
"value": "ABC",
"environment": "Preview"
},
{
"projectIndex": 4,
"key": "XYZ",
"value": "abc3",
"environment": "Preview"
}
]
+7
View File
@@ -0,0 +1,7 @@
[
{
"id": "2379cf1f-a232-4ad2-ae14-4d881131cc26",
"name": "Deploy Tools",
"slug": "deploy-tools"
}
]
+44
View File
@@ -0,0 +1,44 @@
[
{
"projectIndex": 0,
"name": "example.snowballtools.xyz",
"status": "Live",
"branch": "main"
},
{
"projectIndex": 0,
"name": "example.org",
"status": "Pending",
"branch": "test"
},
{
"projectIndex": 1,
"name": "example.snowballtools.xyz",
"status": "Live",
"branch": "main"
},
{
"projectIndex": 1,
"name": "example.org",
"status": "Pending",
"branch": "test"
},
{
"projectIndex": 2,
"name": "example.snowballtools.xyz",
"status": "Live",
"branch": "main"
},
{
"projectIndex": 2,
"name": "example.org",
"status": "Pending",
"branch": "test"
},
{
"projectIndex": 3,
"name": "iglootools-2.com",
"status": "Pending",
"branch": "test"
}
]
+56
View File
@@ -0,0 +1,56 @@
[
{
"memberIndex": 1,
"projectIndex": 0,
"permissions": ["View"],
"isPending": false
},
{
"memberIndex": 2,
"projectIndex": 0,
"permissions": ["View", "Edit"],
"isPending": false
},
{
"memberIndex": 2,
"projectIndex": 1,
"permissions": ["View"],
"isPending": false
},
{
"memberIndex": 0,
"projectIndex": 2,
"permissions": ["View"],
"isPending": false
},
{
"memberIndex": 1,
"projectIndex": 2,
"permissions": ["View", "Edit"],
"isPending": false
},
{
"memberIndex": 0,
"projectIndex": 3,
"permissions": ["View"],
"isPending": false
},
{
"memberIndex": 2,
"projectIndex": 3,
"permissions": ["View", "Edit"],
"isPending": false
},
{
"memberIndex": 1,
"projectIndex": 4,
"permissions": ["View"],
"isPending": false
},
{
"memberIndex": 2,
"projectIndex": 4,
"permissions": ["View", "Edit"],
"isPending": false
}
]
+67
View File
@@ -0,0 +1,67 @@
[
{
"ownerIndex": 0,
"organizationIndex": 0,
"name": "testProject",
"repository": "snowball-tools/snowball-ts-framework-template",
"prodBranch": "main",
"description": "test",
"template": "webapp",
"framework": "test",
"webhooks": [],
"icon": "",
"subDomain": "testProject.snowball.xyz"
},
{
"ownerIndex": 1,
"organizationIndex": 0,
"name": "testProject-2",
"repository": "snowball-tools/snowball-ts-framework-template",
"prodBranch": "main",
"description": "test-2",
"template": "webapp",
"framework": "test-2",
"webhooks": [],
"icon": "",
"subDomain": "testProject-2.snowball.xyz"
},
{
"ownerIndex": 2,
"organizationIndex": 0,
"name": "iglootools",
"repository": "snowball-tools/snowball-ts-framework-template",
"prodBranch": "main",
"description": "test-3",
"template": "webapp",
"framework": "test-3",
"webhooks": [],
"icon": "",
"subDomain": "iglootools.snowball.xyz"
},
{
"ownerIndex": 1,
"organizationIndex": 0,
"name": "iglootools-2",
"repository": "snowball-tools/snowball-ts-framework-template",
"prodBranch": "main",
"description": "test-4",
"template": "webapp",
"framework": "test-4",
"webhooks": [],
"icon": "",
"subDomain": "iglootools-2.snowball.xyz"
},
{
"ownerIndex": 0,
"organizationIndex": 1,
"name": "snowball-2",
"repository": "snowball-tools/snowball-ts-framework-template",
"prodBranch": "main",
"description": "test-5",
"template": "webapp",
"framework": "test-5",
"webhooks": [],
"icon": "",
"subDomain": "snowball-2.snowball.xyz"
}
]
+23
View File
@@ -0,0 +1,23 @@
[
{
"projectIndex": 0,
"name": "www.example.org",
"status": "Pending",
"redirectToIndex": 1,
"branch": "test"
},
{
"projectIndex": 1,
"name": "www.example.org",
"status": "Pending",
"redirectToIndex": 3,
"branch": "test"
},
{
"projectIndex": 2,
"name": "www.example.org",
"status": "Pending",
"redirectToIndex": 5,
"branch": "test"
}
]
+22
View File
@@ -0,0 +1,22 @@
[
{
"role": "Owner",
"memberIndex": 0,
"organizationIndex": 0
},
{
"role": "Maintainer",
"memberIndex": 1,
"organizationIndex": 0
},
{
"role": "Owner",
"memberIndex": 2,
"organizationIndex": 0
},
{
"role": "Owner",
"memberIndex": 0,
"organizationIndex": 1
}
]
+23
View File
@@ -0,0 +1,23 @@
[
{
"id": "59f4355d-9549-4aac-9b54-eeefceeabef0",
"name": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"email": "snowball@snowballtools.xyz",
"isVerified": true,
"ethAddress": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
},
{
"id": "e505b212-8da6-48b2-9614-098225dab34b",
"name": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8",
"email": "alice@snowballtools.xyz",
"isVerified": true,
"ethAddress": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8"
},
{
"id": "cd892fad-9138-4aa2-a62c-414a32776ea7",
"name": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a",
"email": "bob@snowballtools.xyz",
"isVerified": true,
"ethAddress": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a"
}
]
+176
View File
@@ -0,0 +1,176 @@
import path from 'node:path'
import debug from 'debug'
import { DataSource } from 'typeorm'
import { Deployment } from '../src/entity/Deployment'
import { Domain } from '../src/entity/Domain'
import { EnvironmentVariable } from '../src/entity/EnvironmentVariable'
import { Organization } from '../src/entity/Organization'
import { Project } from '../src/entity/Project'
import { ProjectMember } from '../src/entity/ProjectMember'
import { User } from '../src/entity/User'
import { UserOrganization } from '../src/entity/UserOrganization'
import {
checkFileExists,
getConfig,
getEntities,
loadAndSaveData
} from '../src/utils'
const log = debug('snowball:initialize-database')
const USER_DATA_PATH = './fixtures/users.json'
const PROJECT_DATA_PATH = './fixtures/projects.json'
const ORGANIZATION_DATA_PATH = './fixtures/organizations.json'
const USER_ORGANIZATION_DATA_PATH = './fixtures/user-organizations.json'
const PROJECT_MEMBER_DATA_PATH = './fixtures/project-members.json'
const PRIMARY_DOMAIN_DATA_PATH = './fixtures/primary-domains.json'
const DEPLOYMENT_DATA_PATH = './fixtures/deployments.json'
const ENVIRONMENT_VARIABLE_DATA_PATH = './fixtures/environment-variables.json'
const REDIRECTED_DOMAIN_DATA_PATH = './fixtures/redirected-domains.json'
const generateTestData = async (dataSource: DataSource) => {
const userEntities = await getEntities(
path.resolve(__dirname, USER_DATA_PATH)
)
const savedUsers = await loadAndSaveData(User, dataSource, userEntities)
const orgEntities = await getEntities(
path.resolve(__dirname, ORGANIZATION_DATA_PATH)
)
const savedOrgs = await loadAndSaveData(Organization, dataSource, orgEntities)
const projectRelations = {
owner: savedUsers,
organization: savedOrgs
}
const projectEntities = await getEntities(
path.resolve(__dirname, PROJECT_DATA_PATH)
)
const savedProjects = await loadAndSaveData(
Project,
dataSource,
projectEntities,
projectRelations
)
const domainRepository = dataSource.getRepository(Domain)
const domainPrimaryRelations = {
project: savedProjects
}
const primaryDomainsEntities = await getEntities(
path.resolve(__dirname, PRIMARY_DOMAIN_DATA_PATH)
)
const savedPrimaryDomains = await loadAndSaveData(
Domain,
dataSource,
primaryDomainsEntities,
domainPrimaryRelations
)
const domainRedirectedRelations = {
project: savedProjects,
redirectTo: savedPrimaryDomains
}
const redirectDomainsEntities = await getEntities(
path.resolve(__dirname, REDIRECTED_DOMAIN_DATA_PATH)
)
await loadAndSaveData(
Domain,
dataSource,
redirectDomainsEntities,
domainRedirectedRelations
)
const savedDomains = await domainRepository.find()
const userOrganizationRelations = {
member: savedUsers,
organization: savedOrgs
}
const userOrganizationsEntities = await getEntities(
path.resolve(__dirname, USER_ORGANIZATION_DATA_PATH)
)
await loadAndSaveData(
UserOrganization,
dataSource,
userOrganizationsEntities,
userOrganizationRelations
)
const projectMemberRelations = {
member: savedUsers,
project: savedProjects
}
const projectMembersEntities = await getEntities(
path.resolve(__dirname, PROJECT_MEMBER_DATA_PATH)
)
await loadAndSaveData(
ProjectMember,
dataSource,
projectMembersEntities,
projectMemberRelations
)
const deploymentRelations = {
project: savedProjects,
domain: savedDomains,
createdBy: savedUsers
}
const deploymentsEntities = await getEntities(
path.resolve(__dirname, DEPLOYMENT_DATA_PATH)
)
await loadAndSaveData(
Deployment,
dataSource,
deploymentsEntities,
deploymentRelations
)
const environmentVariableRelations = {
project: savedProjects
}
const environmentVariablesEntities = await getEntities(
path.resolve(__dirname, ENVIRONMENT_VARIABLE_DATA_PATH)
)
await loadAndSaveData(
EnvironmentVariable,
dataSource,
environmentVariablesEntities,
environmentVariableRelations
)
}
const main = async () => {
const config = await getConfig()
const isDbPresent = await checkFileExists(config.database.dbPath)
if (!isDbPresent) {
const dataSource = new DataSource({
type: 'better-sqlite3',
database: config.database.dbPath,
synchronize: true,
logging: true,
entities: [path.join(__dirname, '../src/entity/*')]
})
await dataSource.initialize()
await generateTestData(dataSource)
log('Data loaded successfully')
} else {
log('WARNING: Database already exists')
}
}
main().catch((err) => {
log(err)
})
+49
View File
@@ -0,0 +1,49 @@
import debug from 'debug'
import { Registry, parseGasAndFees } from '@cerc-io/registry-sdk'
import { getConfig } from '../src/utils'
const log = debug('snowball:initialize-registry')
const DENOM = 'alnt'
const BOND_AMOUNT = '1000000000'
async function main() {
const { registryConfig } = await getConfig()
// TODO: Get authority names from args
const authorityNames = ['snowballtools', registryConfig.authority]
const registry = new Registry(
registryConfig.gqlEndpoint,
registryConfig.restEndpoint,
{ chainId: registryConfig.chainId }
)
const bondId = await registry.getNextBondId(registryConfig.privateKey)
log('bondId:', bondId)
const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees)
await registry.createBond(
{ denom: DENOM, amount: BOND_AMOUNT },
registryConfig.privateKey,
fee
)
for await (const name of authorityNames) {
await registry.reserveAuthority({ name }, registryConfig.privateKey, fee)
log('Reserved authority name:', name)
await registry.setAuthorityBond(
{ name, bondId },
registryConfig.privateKey,
fee
)
log(`Bond ${bondId} set for authority ${name}`)
}
}
main().catch((err) => {
log(err)
})
+100
View File
@@ -0,0 +1,100 @@
import path from 'node:path'
import debug from 'debug'
import { DataSource } from 'typeorm'
import { Registry, parseGasAndFees } from '@cerc-io/registry-sdk'
import {
Deployment,
DeploymentStatus,
Environment
} from '../src/entity/Deployment'
import { getConfig } from '../src/utils'
const log = debug('snowball:publish-deploy-records')
async function main() {
const { registryConfig, database } = await getConfig()
const registry = new Registry(
registryConfig.gqlEndpoint,
registryConfig.restEndpoint,
{ chainId: registryConfig.chainId }
)
const dataSource = new DataSource({
type: 'better-sqlite3',
database: database.dbPath,
synchronize: true,
entities: [path.join(__dirname, '../src/entity/*')]
})
await dataSource.initialize()
const deploymentRepository = dataSource.getRepository(Deployment)
const deployments = await deploymentRepository.find({
relations: {
project: true
},
where: {
status: DeploymentStatus.Building
}
})
for await (const deployment of deployments) {
const url = `https://${(deployment.project.name).toLowerCase()}-${deployment.id}.${deployment.deployer.baseDomain}`
const applicationDeploymentRecord = {
type: 'ApplicationDeploymentRecord',
version: '0.0.1',
name: deployment.applicationRecordData.name,
application: deployment.applicationRecordId,
// TODO: Create DNS record
dns: 'bafyreihlymqggsgqiqawvehkpr2imt4l3u6q7um7xzjrux5rhsvwnuyewm',
// Using dummy values
meta: JSON.stringify({
config: 'da39a3ee5e6b4b0d3255bfef95601890afd80709',
so: '66fcfa49a1664d4cb4ce4f72c1c0e151'
}),
request: deployment.applicationDeploymentRequestId,
url
}
const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees)
const result = await registry.setRecord(
{
privateKey: registryConfig.privateKey,
record: applicationDeploymentRecord,
bondId: registryConfig.bondId
},
'',
fee
)
// Remove deployment for project subdomain if deployment is for production environment
if (deployment.environment === Environment.Production) {
applicationDeploymentRecord.url = `https://${deployment.project.name}.${deployment.deployer.baseDomain}`
await registry.setRecord(
{
privateKey: registryConfig.privateKey,
record: applicationDeploymentRecord,
bondId: registryConfig.bondId
},
'',
fee
)
}
log('Application deployment record data:', applicationDeploymentRecord)
log(`Application deployment record published: ${result.id}`)
}
}
main().catch((err) => {
log(err)
})
@@ -0,0 +1,70 @@
import path from 'node:path'
import debug from 'debug'
import { DataSource } from 'typeorm'
import { Registry, parseGasAndFees } from '@cerc-io/registry-sdk'
import { Deployment, DeploymentStatus } from '../src/entity/Deployment'
import { getConfig } from '../src/utils'
const log = debug('snowball:publish-deployment-removal-records')
async function main() {
const { registryConfig, database } = await getConfig()
const registry = new Registry(
registryConfig.gqlEndpoint,
registryConfig.restEndpoint,
{ chainId: registryConfig.chainId }
)
const dataSource = new DataSource({
type: 'better-sqlite3',
database: database.dbPath,
synchronize: true,
entities: [path.join(__dirname, '../src/entity/*')]
})
await dataSource.initialize()
const deploymentRepository = dataSource.getRepository(Deployment)
const deployments = await deploymentRepository.find({
relations: {
project: true
},
where: {
status: DeploymentStatus.Deleting
}
})
for await (const deployment of deployments) {
const applicationDeploymentRemovalRecord = {
type: 'ApplicationDeploymentRemovalRecord',
version: '1.0.0',
deployment: deployment.applicationDeploymentRecordId,
request: deployment.applicationDeploymentRemovalRequestId
}
const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees)
const result = await registry.setRecord(
{
privateKey: registryConfig.privateKey,
record: applicationDeploymentRemovalRecord,
bondId: registryConfig.bondId
},
'',
fee
)
log(
'Application deployment removal record data:',
applicationDeploymentRemovalRecord
)
log(`Application deployment removal record published: ${result.id}`)
}
}
main().catch((err) => {
log(err)
})
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"baseUrl": ".",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["dist", "src/**/*.test.ts"]
}