Compare commits

..

No commits in common. "ng-deploy-with-fixes" and "main" have entirely different histories.

69 changed files with 9018 additions and 1052 deletions

76
apps/backend/README.md Normal file
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
apps/backend/biome.json Normal file
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/**/*"]
}
}

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"

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"

68
apps/backend/package.json Normal file
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"
}
}

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
}
}

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'

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
}
}

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[]
}

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
}

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
}

View File

@ -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
}

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[]
}

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[]
}

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
}

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[]
}

View File

@ -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
apps/backend/src/index.ts Normal file
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)
})

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
}
}

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
}
}
}
}
}

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

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

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
apps/backend/src/schema.gql Normal file
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
apps/backend/src/server.ts Normal file
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
}

1783
apps/backend/src/service.ts Normal file

File diff suppressed because it is too large Load Diff

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
apps/backend/src/types.ts Normal file
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
apps/backend/src/utils.ts Normal file
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}`
)
}
}
}

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))

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"
}
]

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"
}
]

View File

@ -0,0 +1,7 @@
[
{
"id": "2379cf1f-a232-4ad2-ae14-4d881131cc26",
"name": "Deploy Tools",
"slug": "deploy-tools"
}
]

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"
}
]

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
}
]

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"
}
]

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"
}
]

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
apps/backend/test/fixtures/users.json vendored Normal file
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"
}
]

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)
})

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)
})

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)
})

View File

@ -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)
})

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"]
}

View File

@ -6,8 +6,6 @@
"scripts": {
"dev": "NODE_OPTIONS='--inspect' next dev --turbopack",
"build": "next build",
"build:compile": "next build --experimental-build-mode compile",
"build:generate": "next build --experimental-build-mode generate",
"start": "next start",
"lint": "biome check .",
"lint:fix": "biome check --write .",

View File

@ -1,15 +1,15 @@
'use client'
import { LaconicMark } from '@/components/assets/laconic-mark'
import { ConfigureStep } from '@/components/onboarding/configure-step/configure-step'
import { ConnectStep } from '@/components/onboarding/connect-step/connect-step'
import { DeployStep } from '@/components/onboarding/deploy-step/deploy-step'
import { SuccessStep } from '@/components/onboarding/success-step/success-step'
import { useOnboarding } from '@/components/onboarding/useOnboarding'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { X } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useOnboarding } from '@/components/onboarding/store'
import { ConnectStep } from '@/components/onboarding/connect-step/connect-step'
import { ConfigureStep } from '@/components/onboarding/configure-step/configure-step'
import { DeployStep } from '@/components/onboarding/deploy-step/deploy-step'
import { SuccessStep } from '@/components/onboarding/success-step/success-step'
import { LaconicMark } from '@/components/assets/laconic-mark'
/**
* Parent component for the onboarding flow
@ -55,21 +55,13 @@ export default function CreateProjectFlow() {
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
{/* Fixed dimensions modal container */}
<div
className={`${isDarkMode ? 'bg-black' : 'bg-white'} rounded-xl overflow-hidden flex shadow-2xl w-[1000px] h-[620px]`}
>
<div className={`${isDarkMode ? 'bg-black' : 'bg-white'} rounded-xl overflow-hidden flex shadow-2xl w-[1000px] h-[620px]`}>
{/* Left sidebar with fixed width */}
<div
className={`w-[280px] min-w-[280px] ${isDarkMode ? 'bg-zinc-900' : 'bg-zinc-50'} p-8 relative overflow-hidden border-r ${isDarkMode ? 'border-zinc-800' : 'border-zinc-200'}`}
>
<div className={`w-[280px] min-w-[280px] ${isDarkMode ? 'bg-zinc-900' : 'bg-zinc-50'} p-8 relative overflow-hidden border-r ${isDarkMode ? 'border-zinc-800' : 'border-zinc-200'}`}>
{/* Laconic logo */}
<div className="flex items-center gap-2 mb-12">
<LaconicMark className="h-8 w-8" />
<span
className={`${isDarkMode ? 'text-white' : 'text-zinc-900'} text-xl font-bold`}
>
LACONIC
</span>
<span className={`${isDarkMode ? 'text-white' : 'text-zinc-900'} text-xl font-bold`}>LACONIC</span>
</div>
{/* Steps - clickable */}
@ -80,71 +72,29 @@ export default function CreateProjectFlow() {
onClick={() => navigateToStep('connect')}
>
<div className="mr-4">
<div
className={`w-10 h-10 rounded-lg ${
currentStep === 'connect'
? isDarkMode
? 'bg-white'
: 'bg-black'
: isDarkMode
? 'bg-zinc-800'
: 'bg-zinc-200'
} flex items-center justify-center`}
>
<svg
viewBox="0 0 24 24"
width="20"
height="20"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={
currentStep === 'connect'
? isDarkMode
? 'text-black'
: 'text-white'
: isDarkMode
? 'text-zinc-400'
: 'text-zinc-600'
}
>
<div className={`w-10 h-10 rounded-lg ${currentStep === 'connect'
? (isDarkMode ? 'bg-white' : 'bg-black')
: (isDarkMode ? 'bg-zinc-800' : 'bg-zinc-200')
} flex items-center justify-center`}>
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" className={currentStep === 'connect'
? (isDarkMode ? 'text-black' : 'text-white')
: (isDarkMode ? 'text-zinc-400' : 'text-zinc-600')
}>
<path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
<line x1="8" y1="12" x2="16" y2="12"></line>
</svg>
</div>
<div
className={`w-px h-10 ${isDarkMode ? 'bg-zinc-800' : 'bg-zinc-200'} mx-auto mt-2`}
></div>
<div className={`w-px h-10 ${isDarkMode ? 'bg-zinc-800' : 'bg-zinc-200'} mx-auto mt-2`}></div>
</div>
<div>
<h3
className={`font-medium text-base ${
currentStep === 'connect'
? isDarkMode
? 'text-white'
: 'text-zinc-900'
: isDarkMode
? 'text-zinc-400'
: 'text-zinc-600'
}`}
>
Connect
</h3>
<p
className={`text-sm ${
currentStep === 'connect'
? isDarkMode
? 'text-zinc-300'
: 'text-zinc-700'
: isDarkMode
? 'text-zinc-500'
: 'text-zinc-500'
}`}
>
Connect and import a GitHub repo
</p>
<h3 className={`font-medium text-base ${currentStep === 'connect'
? (isDarkMode ? 'text-white' : 'text-zinc-900')
: (isDarkMode ? 'text-zinc-400' : 'text-zinc-600')
}`}>Connect</h3>
<p className={`text-sm ${currentStep === 'connect'
? (isDarkMode ? 'text-zinc-300' : 'text-zinc-700')
: (isDarkMode ? 'text-zinc-500' : 'text-zinc-500')
}`}>Connect and import a GitHub repo</p>
</div>
</button>
@ -154,71 +104,29 @@ export default function CreateProjectFlow() {
onClick={() => navigateToStep('configure')}
>
<div className="mr-4">
<div
className={`w-10 h-10 rounded-lg ${
currentStep === 'configure'
? isDarkMode
? 'bg-white'
: 'bg-black'
: isDarkMode
? 'bg-zinc-800'
: 'bg-zinc-200'
} flex items-center justify-center`}
>
<svg
viewBox="0 0 24 24"
width="20"
height="20"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={
currentStep === 'configure'
? isDarkMode
? 'text-black'
: 'text-white'
: isDarkMode
? 'text-zinc-400'
: 'text-zinc-600'
}
>
<div className={`w-10 h-10 rounded-lg ${currentStep === 'configure'
? (isDarkMode ? 'bg-white' : 'bg-black')
: (isDarkMode ? 'bg-zinc-800' : 'bg-zinc-200')
} flex items-center justify-center`}>
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" className={currentStep === 'configure'
? (isDarkMode ? 'text-black' : 'text-white')
: (isDarkMode ? 'text-zinc-400' : 'text-zinc-600')
}>
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
</div>
<div
className={`w-px h-10 ${isDarkMode ? 'bg-zinc-800' : 'bg-zinc-200'} mx-auto mt-2`}
></div>
<div className={`w-px h-10 ${isDarkMode ? 'bg-zinc-800' : 'bg-zinc-200'} mx-auto mt-2`}></div>
</div>
<div>
<h3
className={`font-medium text-base ${
currentStep === 'configure'
? isDarkMode
? 'text-white'
: 'text-zinc-900'
: isDarkMode
? 'text-zinc-400'
: 'text-zinc-600'
}`}
>
Configure
</h3>
<p
className={`text-sm ${
currentStep === 'configure'
? isDarkMode
? 'text-zinc-300'
: 'text-zinc-700'
: isDarkMode
? 'text-zinc-500'
: 'text-zinc-500'
}`}
>
Define the deployment type
</p>
<h3 className={`font-medium text-base ${currentStep === 'configure'
? (isDarkMode ? 'text-white' : 'text-zinc-900')
: (isDarkMode ? 'text-zinc-400' : 'text-zinc-600')
}`}>Configure</h3>
<p className={`text-sm ${currentStep === 'configure'
? (isDarkMode ? 'text-zinc-300' : 'text-zinc-700')
: (isDarkMode ? 'text-zinc-500' : 'text-zinc-500')
}`}>Define the deployment type</p>
</div>
</button>
@ -228,36 +136,14 @@ export default function CreateProjectFlow() {
onClick={() => navigateToStep('deploy')}
>
<div className="mr-4">
<div
className={`w-10 h-10 rounded-lg ${
currentStep === 'deploy' || currentStep === 'success'
? isDarkMode
? 'bg-white'
: 'bg-black'
: isDarkMode
? 'bg-zinc-800'
: 'bg-zinc-200'
} flex items-center justify-center`}
>
<svg
viewBox="0 0 24 24"
width="20"
height="20"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={
currentStep === 'deploy' || currentStep === 'success'
? isDarkMode
? 'text-black'
: 'text-white'
: isDarkMode
? 'text-zinc-400'
: 'text-zinc-600'
}
>
<div className={`w-10 h-10 rounded-lg ${currentStep === 'deploy' || currentStep === 'success'
? (isDarkMode ? 'bg-white' : 'bg-black')
: (isDarkMode ? 'bg-zinc-800' : 'bg-zinc-200')
} flex items-center justify-center`}>
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" className={currentStep === 'deploy' || currentStep === 'success'
? (isDarkMode ? 'text-black' : 'text-white')
: (isDarkMode ? 'text-zinc-400' : 'text-zinc-600')
}>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="7.5 4.21 12 6.81 16.5 4.21"></polyline>
<polyline points="7.5 19.79 7.5 14.6 3 12"></polyline>
@ -268,48 +154,26 @@ export default function CreateProjectFlow() {
</div>
</div>
<div>
<h3
className={`font-medium text-base ${
currentStep === 'deploy' || currentStep === 'success'
? isDarkMode
? 'text-white'
: 'text-zinc-900'
: isDarkMode
? 'text-zinc-400'
: 'text-zinc-600'
}`}
>
Deploy
</h3>
<p
className={`text-sm ${
currentStep === 'deploy' || currentStep === 'success'
? isDarkMode
? 'text-zinc-300'
: 'text-zinc-700'
: isDarkMode
? 'text-zinc-500'
: 'text-zinc-500'
}`}
>
Review and confirm deployment
</p>
<h3 className={`font-medium text-base ${currentStep === 'deploy' || currentStep === 'success'
? (isDarkMode ? 'text-white' : 'text-zinc-900')
: (isDarkMode ? 'text-zinc-400' : 'text-zinc-600')
}`}>Deploy</h3>
<p className={`text-sm ${currentStep === 'deploy' || currentStep === 'success'
? (isDarkMode ? 'text-zinc-300' : 'text-zinc-700')
: (isDarkMode ? 'text-zinc-500' : 'text-zinc-500')
}`}>Review and confirm deployment</p>
</div>
</button>
</div>
{/* Laconic mark (larger, bottom left) */}
<div className="absolute -bottom-2 -left-2 opacity-10">
<LaconicMark
className={`w-40 h-40 ${isDarkMode ? 'text-zinc-300' : 'text-zinc-700'}`}
/>
<LaconicMark className={`w-40 h-40 ${isDarkMode ? 'text-zinc-300' : 'text-zinc-700'}`} />
</div>
</div>
{/* Main content with fixed dimensions and scrolling */}
<div
className={`flex-1 ${isDarkMode ? 'bg-black' : 'bg-white'} relative`}
>
<div className={`flex-1 ${isDarkMode ? 'bg-black' : 'bg-white'} relative`}>
{/* Close button */}
<button
className={`absolute top-4 right-4 ${isDarkMode ? 'text-zinc-400 hover:text-white' : 'text-zinc-600 hover:text-zinc-900'} z-10`}
@ -328,15 +192,9 @@ export default function CreateProjectFlow() {
{/* Progress indicator */}
<div className="absolute bottom-6 left-0 right-0 flex justify-center gap-3">
<div
className={`w-12 h-1 rounded-full ${currentStep === 'connect' ? 'bg-blue-600' : isDarkMode ? 'bg-zinc-700' : 'bg-zinc-300'}`}
></div>
<div
className={`w-12 h-1 rounded-full ${currentStep === 'configure' ? 'bg-blue-600' : isDarkMode ? 'bg-zinc-700' : 'bg-zinc-300'}`}
></div>
<div
className={`w-12 h-1 rounded-full ${currentStep === 'deploy' || currentStep === 'success' ? 'bg-blue-600' : isDarkMode ? 'bg-zinc-700' : 'bg-zinc-300'}`}
></div>
<div className={`w-12 h-1 rounded-full ${currentStep === 'connect' ? 'bg-blue-600' : (isDarkMode ? 'bg-zinc-700' : 'bg-zinc-300')}`}></div>
<div className={`w-12 h-1 rounded-full ${currentStep === 'configure' ? 'bg-blue-600' : (isDarkMode ? 'bg-zinc-700' : 'bg-zinc-300')}`}></div>
<div className={`w-12 h-1 rounded-full ${currentStep === 'deploy' || currentStep === 'success' ? 'bg-blue-600' : (isDarkMode ? 'bg-zinc-700' : 'bg-zinc-300')}`}></div>
</div>
</div>
</div>

View File

@ -1,10 +1,10 @@
import { Providers } from '@/components/providers'
import { ClerkProvider } from '@clerk/nextjs'
import '@workspace/ui/globals.css'
import { AutoSignInIFrameModal } from '@/components/iframe/auto-sign-in'
import { CheckBalanceWrapper } from '@/components/iframe/check-balance-iframe/CheckBalanceWrapper'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { CheckBalanceWrapper } from '@/components/iframe/check-balance-iframe/CheckBalanceWrapper'
import { AutoSignInIFrameModal } from '@/components/iframe/auto-sign-in'
// Add root metadata with template pattern
export const metadata: Metadata = {
@ -25,10 +25,7 @@ export default function RootLayout({
children
}: Readonly<{ children: React.ReactNode }>) {
return (
<ClerkProvider
publishableKey={process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}
signInFallbackRedirectUrl="/home"
>
<ClerkProvider signInFallbackRedirectUrl="/home">
<html lang="en" suppressHydrationWarning>
<body className={`${inter.className} `} suppressHydrationWarning>
<main>

View File

@ -27,8 +27,6 @@ declare global {
}
}
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
export default function TestConnectionPage() {
// Get getToken from useAuth hook, not from user
const { isSignedIn, isLoaded: isClerkLoaded, getToken } = useAuth()
@ -138,7 +136,7 @@ export default function TestConnectionPage() {
const checkBackendConnection = async () => {
try {
// Test session
const response = await fetch(`${BACKEND_URL}/auth/session`, {
const response = await fetch('http://localhost:8000/auth/session', {
method: 'GET',
credentials: 'include',
})
@ -194,7 +192,7 @@ export default function TestConnectionPage() {
const checkWalletConnection = async () => {
if (isBackendConnected) {
try {
const response = await fetch(`${BACKEND_URL}/auth/session`, {
const response = await fetch('http://localhost:8000/auth/session', {
method: 'GET',
credentials: 'include',
})

View File

@ -6,8 +6,6 @@ import { Button } from '@workspace/ui/components/button'
import { CheckBalanceWrapper } from './iframe/check-balance-iframe/CheckBalanceWrapper'
import { CopyIcon } from 'lucide-react'
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
// Generate a random nonce
function generateNonce() {
return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
@ -27,7 +25,7 @@ export function SIWEAuth() {
const checkSession = async () => {
try {
setSessionStatus('checking')
const response = await fetch(`${BACKEND_URL}/auth/session`, {
const response = await fetch('http://localhost:8000/auth/session', {
method: 'GET',
credentials: 'include',
})
@ -173,7 +171,7 @@ Issued At: ${issuedAt}`
setDebugInfo(prev => `${prev}\nRaw signature: ${signedMessage}`)
// Try using the raw signature directly
const response = await fetch(`${BACKEND_URL}/auth/validate`, {
const response = await fetch('http://localhost:8000/auth/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@ -4,8 +4,6 @@ import { useState, useEffect } from 'react'
import { Button } from '@workspace/ui/components/button'
import { Wallet } from 'ethers' // Add this to your package.json if not already there
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
export function DirectKeyAuth() {
const [sessionStatus, setSessionStatus] = useState<'checking' | 'authenticated' | 'unauthenticated'>('checking')
const [sessionData, setSessionData] = useState<any>(null)
@ -16,7 +14,7 @@ export function DirectKeyAuth() {
const checkSession = async () => {
try {
setSessionStatus('checking')
const response = await fetch(`${BACKEND_URL}/auth/session`, {
const response = await fetch('http://localhost:8000/auth/session', {
method: 'GET',
credentials: 'include',
})
@ -81,7 +79,7 @@ Issued At: ${issuedAt}`;
console.log('Generated signature:', signature);
// Send to backend
const response = await fetch(`${BACKEND_URL}/auth/validate`, {
const response = await fetch('http://localhost:8000/auth/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@ -2,8 +2,6 @@
import { useEffect, useState } from 'react'
import { useGQLClient } from '@/context'
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
export function GQLTest() {
const [testResponse, setTestResponse] = useState<string>('Testing connection...')
const [error, setError] = useState<string | null>(null)
@ -13,7 +11,7 @@ export function GQLTest() {
async function testGQLConnection() {
try {
// Try a direct GraphQL query using fetch
const response = await fetch(`${BACKEND_URL}/graphql`, {
const response = await fetch('http://localhost:8000/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -52,7 +50,7 @@ export function GQLTest() {
setTestResponse('Testing direct connection...')
setError(null)
const response = await fetch(`${BACKEND_URL}/auth/session`, {
const response = await fetch('http://localhost:8000/auth/session', {
method: 'GET',
credentials: 'include',
})

View File

@ -10,7 +10,7 @@
import { ConfigureStep } from '@/components/onboarding/configure-step'
import { ConnectStep } from '@/components/onboarding/connect-step'
import { DeployStep } from '@/components/onboarding/deploy-step'
import { useOnboarding } from '@/components/onboarding/useOnboarding'
import { useOnboarding } from '@/components/onboarding/store'
import { ScrollArea } from '@workspace/ui/components/scroll-area'
import { SidebarNav } from './sidebar'

View File

@ -1,33 +1,22 @@
// src/components/onboarding/configure-step/configure-step.tsx
'use client'
import { useOnboarding } from '@/components/onboarding/useOnboarding'
import { useState, useEffect } from 'react'
import { PlusCircle, Loader2, AlertTriangle, Info } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useOnboarding } from '@/components/onboarding/store'
import { useGQLClient } from '@/context'
import { useWallet } from '@/context/WalletContext'
import { Alert, AlertDescription } from '@workspace/ui/components/alert'
import { Badge } from '@workspace/ui/components/badge'
import { Button } from '@workspace/ui/components/button'
import {
Card,
CardContent,
CardHeader,
CardTitle
} from '@workspace/ui/components/card'
import { Checkbox } from '@workspace/ui/components/checkbox'
import { Input } from '@workspace/ui/components/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@workspace/ui/components/select'
import { Checkbox } from '@workspace/ui/components/checkbox'
import { Label } from '@workspace/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@workspace/ui/components/select'
import { AlertTriangle, Info, Loader2, PlusCircle } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import { Alert, AlertDescription } from '@workspace/ui/components/alert'
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card'
import { Badge } from '@workspace/ui/components/badge'
import { toast } from 'sonner'
import { adaptDeployers } from '../../../utils/typeAdapters'
import { adaptDeployers } from '../../../utils/typeAdapters';
interface Deployer {
deployerLrn: string
@ -55,21 +44,23 @@ export function ConfigureStep() {
// Form state
const [deployOption, setDeployOption] = useState<'auction' | 'lrn'>(
(formData.deploymentType as 'auction' | 'lrn') || 'lrn' // Default to LRN for simplicity
formData.deploymentType as ('auction' | 'lrn') || 'lrn' // Default to LRN for simplicity
)
const [numberOfDeployers, setNumberOfDeployers] = useState<string>(
formData.deployerCount || '1'
formData.deployerCount || "1"
)
const [maxPrice, setMaxPrice] = useState<string>(
formData.maxPrice || "1000"
)
const [maxPrice, setMaxPrice] = useState<string>(formData.maxPrice || '1000')
const [selectedLrn, setSelectedLrn] = useState<string>(
formData.selectedLrn || ''
formData.selectedLrn || ""
)
const [selectedOrg, setSelectedOrg] = useState<string>(
formData.selectedOrg || ''
formData.selectedOrg || ""
)
const [envVars, setEnvVars] = useState<
{ key: string; value: string; environments: string[] }[]
>([{ key: '', value: '', environments: ['Production'] }])
const [envVars, setEnvVars] = useState<{ key: string; value: string; environments: string[] }[]>([
{ key: '', value: '', environments: ['Production'] }
])
// Contexts
const gqlClient = useGQLClient()
@ -90,15 +81,10 @@ export function ConfigureStep() {
// Initialize environment variables from formData if available
useEffect(() => {
if (
formData.environmentVariables &&
Array.isArray(formData.environmentVariables)
) {
setEnvVars(
formData.environmentVariables.length > 0
? formData.environmentVariables
: [{ key: '', value: '', environments: ['Production'] }]
)
if (formData.environmentVariables && Array.isArray(formData.environmentVariables)) {
setEnvVars(formData.environmentVariables.length > 0 ? formData.environmentVariables : [
{ key: '', value: '', environments: ['Production'] }
])
}
}, [formData.environmentVariables])
@ -108,14 +94,10 @@ export function ConfigureStep() {
setIsLoadingDeployers(true)
const deployersData = await gqlClient.getDeployers()
console.log('Available deployers:', deployersData)
setDeployers(adaptDeployers(deployersData.deployers || []))
setDeployers(adaptDeployers(deployersData.deployers || []));
// Auto-select first deployer if available and none selected
if (
deployersData.deployers &&
deployersData.deployers.length > 0 &&
!selectedLrn
) {
if (deployersData.deployers && deployersData.deployers.length > 0 && !selectedLrn) {
setSelectedLrn(deployersData.deployers[0]!.deployerLrn)
}
} catch (error) {
@ -135,11 +117,7 @@ export function ConfigureStep() {
setOrganizations(orgsData.organizations || [])
// Auto-select first organization if available and none selected
if (
orgsData.organizations &&
orgsData.organizations.length > 0 &&
!selectedOrg
) {
if (orgsData.organizations && orgsData.organizations.length > 0 && !selectedOrg) {
setSelectedOrg(orgsData.organizations[0]!.slug)
}
} catch (error) {
@ -152,10 +130,7 @@ export function ConfigureStep() {
// Add an empty environment variable row
const addEnvVar = () => {
setEnvVars([
...envVars,
{ key: '', value: '', environments: ['Production'] }
])
setEnvVars([...envVars, { key: '', value: '', environments: ['Production'] }])
}
// Remove environment variable row
@ -166,11 +141,7 @@ export function ConfigureStep() {
}
// Update environment variable
const updateEnvVar = (
index: number,
field: 'key' | 'value',
value: string
) => {
const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => {
const newEnvVars = [...envVars]
if (newEnvVars[index]) {
newEnvVars[index][field] = value
@ -184,10 +155,9 @@ export function ConfigureStep() {
if (newEnvVars[index]?.environments) {
const currentEnvs = newEnvVars[index].environments
if (currentEnvs.includes(environment)) {
newEnvVars[index].environments = currentEnvs.filter(
(env) => env !== environment
)
newEnvVars[index].environments = currentEnvs.filter(env => env !== environment)
} else {
newEnvVars[index].environments = [...currentEnvs, environment]
}
@ -207,7 +177,7 @@ export function ConfigureStep() {
}
// Get selected deployer details
const selectedDeployer = deployers.find((d) => d.deployerLrn === selectedLrn)
const selectedDeployer = deployers.find(d => d.deployerLrn === selectedLrn)
// Validate form
const canProceed = () => {
@ -225,9 +195,7 @@ export function ConfigureStep() {
}
// Filter out empty environment variables
const validEnvVars = envVars.filter(
(env) => env.key.trim() && env.value.trim()
)
const validEnvVars = envVars.filter(env => env.key.trim() && env.value.trim())
// Save configuration to form data
setFormData({
@ -253,44 +221,19 @@ export function ConfigureStep() {
// Get deployment mode info
const isTemplateMode = formData.deploymentMode === 'template'
const selectedItem = isTemplateMode
? formData.template?.name
: formData.githubRepo
const selectedItem = isTemplateMode ? formData.template?.name : formData.githubRepo
return (
<div className="w-full h-full flex flex-col p-8 overflow-y-auto">
{/* Configure icon and header */}
<div className="flex flex-col items-center justify-center mb-8">
<div className="mb-4">
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={isDarkMode ? 'text-white' : 'text-black'}
>
<path
d="M12 20h9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className={isDarkMode ? "text-white" : "text-black"}>
<path d="M12 20h9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<h2
className={`text-2xl font-medium text-center mb-2 ${isDarkMode ? 'text-white' : 'text-zinc-900'}`}
>
Configure
</h2>
<h2 className={`text-2xl font-medium text-center mb-2 ${isDarkMode ? "text-white" : "text-zinc-900"}`}>Configure</h2>
<p className={`text-center text-zinc-500 max-w-md`}>
Define the deployment type
</p>
@ -309,9 +252,7 @@ export function ConfigureStep() {
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Type:</span>
<Badge variant="secondary">
{isTemplateMode ? 'Template' : 'Repository'}
</Badge>
<Badge variant="secondary">{isTemplateMode ? 'Template' : 'Repository'}</Badge>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Source:</span>
@ -327,33 +268,24 @@ export function ConfigureStep() {
{/* Organization Selection */}
<div className="mb-6">
<Label
htmlFor="organization"
className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}
>
<Label htmlFor="organization" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Organization *
</Label>
{isLoadingOrgs ? (
<div className="flex items-center justify-center p-3 border rounded-md">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm text-muted-foreground">
Loading organizations...
</span>
<span className="text-sm text-muted-foreground">Loading organizations...</span>
</div>
) : organizations.length === 0 ? (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
No organizations found. You need to be part of at least one
organization.
No organizations found. You need to be part of at least one organization.
</AlertDescription>
</Alert>
) : (
<Select value={selectedOrg} onValueChange={setSelectedOrg}>
<SelectTrigger
id="organization"
className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}
>
<SelectTrigger id="organization" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
<SelectValue placeholder="Select organization" />
</SelectTrigger>
<SelectContent>
@ -369,38 +301,24 @@ export function ConfigureStep() {
{/* Deployment options */}
<div className="mb-6">
<Label
className={`text-sm mb-3 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}
>
<Label className={`text-sm mb-3 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Deployment Type
</Label>
<div className="grid grid-cols-2 gap-2">
<Button
variant={deployOption === 'lrn' ? 'default' : 'outline'}
className={`py-3 ${
deployOption === 'lrn'
? isDarkMode
? 'bg-zinc-800 text-white'
: 'bg-zinc-800 text-white'
: isDarkMode
? 'bg-transparent border-zinc-700 text-zinc-400'
: 'bg-transparent border-zinc-300 text-zinc-600'
}`}
variant={deployOption === 'lrn' ? "default" : "outline"}
className={`py-3 ${deployOption === 'lrn'
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white')
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`}
onClick={() => toggleDeployOption('lrn')}
>
Deployer LRN
</Button>
<Button
variant={deployOption === 'auction' ? 'default' : 'outline'}
className={`py-3 ${
deployOption === 'auction'
? isDarkMode
? 'bg-zinc-800 text-white'
: 'bg-zinc-800 text-white'
: isDarkMode
? 'bg-transparent border-zinc-700 text-zinc-400'
: 'bg-transparent border-zinc-300 text-zinc-600'
}`}
variant={deployOption === 'auction' ? "default" : "outline"}
className={`py-3 ${deployOption === 'auction'
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white')
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`}
onClick={() => toggleDeployOption('auction')}
>
Create Auction
@ -411,18 +329,13 @@ export function ConfigureStep() {
{deployOption === 'lrn' ? (
/* LRN Deployment Settings */
<div className="mb-6">
<Label
htmlFor="lrn"
className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}
>
<Label htmlFor="lrn" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Select Deployer LRN *
</Label>
{isLoadingDeployers ? (
<div className="flex items-center justify-center p-3 border rounded-md">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm text-muted-foreground">
Loading deployers...
</span>
<span className="text-sm text-muted-foreground">Loading deployers...</span>
</div>
) : deployers.length === 0 ? (
<Alert>
@ -434,20 +347,12 @@ export function ConfigureStep() {
) : (
<>
<Select value={selectedLrn} onValueChange={setSelectedLrn}>
<SelectTrigger
id="lrn"
className={
isDarkMode ? 'border-zinc-700' : 'border-zinc-300'
}
>
<SelectTrigger id="lrn" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
<SelectValue placeholder="Select a deployer" />
</SelectTrigger>
<SelectContent>
{deployers.map((deployer) => (
<SelectItem
key={deployer.deployerLrn}
value={deployer.deployerLrn}
>
<SelectItem key={deployer.deployerLrn} value={deployer.deployerLrn}>
<div className="flex flex-col">
<span>{deployer.deployerLrn}</span>
{deployer.minimumPayment && (
@ -465,19 +370,10 @@ export function ConfigureStep() {
{selectedDeployer && (
<div className="mt-3 p-3 bg-muted rounded-md">
<div className="text-sm space-y-1">
<div>
<strong>API URL:</strong>{' '}
{selectedDeployer.deployerApiUrl}
</div>
<div>
<strong>Base Domain:</strong>{' '}
{selectedDeployer.baseDomain}
</div>
<div><strong>API URL:</strong> {selectedDeployer.deployerApiUrl}</div>
<div><strong>Base Domain:</strong> {selectedDeployer.baseDomain}</div>
{selectedDeployer.minimumPayment && (
<div>
<strong>Minimum Payment:</strong>{' '}
{selectedDeployer.minimumPayment}
</div>
<div><strong>Minimum Payment:</strong> {selectedDeployer.minimumPayment}</div>
)}
</div>
</div>
@ -489,20 +385,11 @@ export function ConfigureStep() {
/* Auction Settings */
<div className="grid grid-cols-2 gap-4 mb-6">
<div>
<Label
htmlFor="deployers"
className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}
>
<Label htmlFor="deployers" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Number of Deployers
</Label>
<Select
value={numberOfDeployers}
onValueChange={setNumberOfDeployers}
>
<SelectTrigger
id="deployers"
className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}
>
<Select value={numberOfDeployers} onValueChange={setNumberOfDeployers}>
<SelectTrigger id="deployers" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
<SelectValue placeholder="Select number" />
</SelectTrigger>
<SelectContent>
@ -515,17 +402,11 @@ export function ConfigureStep() {
</Select>
</div>
<div>
<Label
htmlFor="maxPrice"
className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}
>
<Label htmlFor="maxPrice" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Maximum Price (aint)
</Label>
<Select value={maxPrice} onValueChange={setMaxPrice}>
<SelectTrigger
id="maxPrice"
className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}
>
<SelectTrigger id="maxPrice" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
<SelectValue placeholder="Select price" />
</SelectTrigger>
<SelectContent>
@ -541,14 +422,10 @@ export function ConfigureStep() {
{/* Payment Address */}
<div className="mb-6">
<Label
className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}
>
<Label className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Payment Address
</Label>
<div
className={`p-3 border rounded-md bg-muted ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}
>
<div className={`p-3 border rounded-md bg-muted ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}>
<div className="text-sm font-mono break-all">
{wallet?.address || 'No wallet connected'}
</div>
@ -557,19 +434,12 @@ export function ConfigureStep() {
{/* Environment Variables */}
<div className="mb-6">
<Label
className={`text-sm font-medium mb-2 block ${isDarkMode ? 'text-white' : 'text-zinc-900'}`}
>
<Label className={`text-sm font-medium mb-2 block ${isDarkMode ? 'text-white' : 'text-zinc-900'}`}>
Environment Variables
</Label>
<div
className={`border rounded-md p-4 ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}
>
<div className={`border rounded-md p-4 ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}>
{envVars.map((envVar, index) => (
<div
key={index}
className="space-y-2 mb-4 pb-4 border-b border-muted last:border-b-0 last:mb-0 last:pb-0"
>
<div key={index} className="space-y-2 mb-4 pb-4 border-b border-muted last:border-b-0 last:mb-0 last:pb-0">
<div className="grid grid-cols-2 gap-2">
<Input
placeholder="KEY"
@ -580,16 +450,12 @@ export function ConfigureStep() {
<Input
placeholder="VALUE"
value={envVar.value}
onChange={(e) =>
updateEnvVar(index, 'value', e.target.value)
}
onChange={(e) => updateEnvVar(index, 'value', e.target.value)}
className={`bg-transparent ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}
/>
</div>
<div className="flex items-center gap-4">
<span className="text-xs text-muted-foreground">
Environments:
</span>
<span className="text-xs text-muted-foreground">Environments:</span>
{['Production', 'Preview', 'Development'].map((env) => (
<div key={env} className="flex items-center gap-1">
<Checkbox

View File

@ -1,36 +1,24 @@
// src/components/onboarding/connect-step/connect-step.tsx
'use client'
import { GitHubBackendAuth } from '@/components/GitHubBackendAuth'
import { useOnboarding } from '@/components/onboarding/useOnboarding'
import { AVAILABLE_TEMPLATES, type TemplateDetail } from '@/constants/templates'
import { useState, useEffect } from 'react'
import { Github, Wallet, CheckCircle2, AlertTriangle, Loader2, ExternalLink, ChevronDown } from 'lucide-react'
import { useTheme } from 'next-themes'
import { SignIn } from '@clerk/nextjs'
import { useOnboarding } from '@/components/onboarding/store'
import { useAuthStatus } from '@/hooks/useAuthStatus'
import { useRepoData } from '@/hooks/useRepoData'
import type { Template } from '@/types/onboarding'
import { adaptOptionalTemplate } from '@/utils/typeAdapters'
import { SignIn } from '@clerk/nextjs'
import { Alert, AlertDescription } from '@workspace/ui/components/alert'
import { Button } from '@workspace/ui/components/button'
import { Card, CardContent } from '@workspace/ui/components/card'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from '@workspace/ui/components/collapsible'
import { Input } from '@workspace/ui/components/input'
import { Label } from '@workspace/ui/components/label'
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ExternalLink,
Github,
Loader2,
Wallet
} from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import { Alert, AlertDescription } from '@workspace/ui/components/alert'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@workspace/ui/components/collapsible'
import { toast } from 'sonner'
import { GitHubBackendAuth } from '@/components/GitHubBackendAuth'
import { AVAILABLE_TEMPLATES, type TemplateDetail } from '@/constants/templates'
import { Template } from '@/types/onboarding'
import { adaptOptionalTemplate } from '@/utils/typeAdapters'
interface Repository {
id: string | number
@ -45,15 +33,11 @@ export function ConnectStep() {
const [mounted, setMounted] = useState(false)
// Repository vs Template selection
const [selectedRepo, setSelectedRepo] = useState<string>(
formData.githubRepo || ''
)
const [selectedRepo, setSelectedRepo] = useState<string>(formData.githubRepo || '')
const [selectedTemplate, setSelectedTemplate] = useState(
adaptOptionalTemplate(formData.template)
)
const [projectName, setProjectName] = useState<string>(
formData.projectName || ''
)
const [projectName, setProjectName] = useState<string>(formData.projectName || '')
const [isImportMode, setIsImportMode] = useState(true)
// Auth status and warning display
@ -110,9 +94,7 @@ export function ConnectStep() {
template: template,
githubRepo: '',
deploymentMode: 'template',
projectName:
projectName ||
`my-${template.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`
projectName: projectName || `my-${template.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`
})
}
@ -181,8 +163,7 @@ export function ConnectStep() {
}
// For repository import, project name is optional but we'll use repo name as fallback
const finalProjectName =
projectName.trim() || (isImportMode ? selectedRepo.split('/')[1] : '')
const finalProjectName = projectName.trim() || (isImportMode ? selectedRepo.split('/')[1] : '')
// Set final form data and proceed
setFormData({
@ -201,9 +182,7 @@ export function ConnectStep() {
<div className="w-full h-full flex items-center justify-center">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
<p className="text-sm text-zinc-500">
Loading authentication status...
</p>
<p className="text-sm text-zinc-500">Loading authentication status...</p>
</div>
</div>
)
@ -217,9 +196,7 @@ export function ConnectStep() {
<div className="max-w-2xl w-full mx-auto">
{/* Header */}
<div className="text-center mb-8">
<h2
className={`text-2xl font-medium ${isDarkMode ? 'text-white' : 'text-zinc-900'} mb-2`}
>
<h2 className={`text-2xl font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} mb-2`}>
Connect
</h2>
<p className="text-zinc-500 mb-6">
@ -227,20 +204,17 @@ export function ConnectStep() {
</p>
{/* GitHub Account Selector - Only show if multiple accounts */}
{clerk.user?.externalAccounts &&
clerk.user.externalAccounts.length > 1 && (
<div className="flex items-center justify-center mb-6">
<div className="flex items-center gap-2 px-4 py-2 bg-zinc-100 dark:bg-zinc-800 rounded-md cursor-pointer hover:bg-zinc-200 dark:hover:bg-zinc-700">
<Github className="h-4 w-4" />
<span className="text-sm font-medium">
{clerk.user?.externalAccounts?.find(
(acc: any) => acc.provider === 'github'
)?.username || 'git-account'}
</span>
<ChevronDown className="h-4 w-4" />
</div>
{clerk.user?.externalAccounts && clerk.user.externalAccounts.length > 1 && (
<div className="flex items-center justify-center mb-6">
<div className="flex items-center gap-2 px-4 py-2 bg-zinc-100 dark:bg-zinc-800 rounded-md cursor-pointer hover:bg-zinc-200 dark:hover:bg-zinc-700">
<Github className="h-4 w-4" />
<span className="text-sm font-medium">
{clerk.user?.externalAccounts?.find((acc: any) => acc.provider === 'github')?.username || 'git-account'}
</span>
<ChevronDown className="h-4 w-4" />
</div>
)}
</div>
)}
</div>
{/* Authentication Warning - Only show if not fully authenticated */}
@ -250,10 +224,7 @@ export function ConnectStep() {
<Alert className="mb-6 cursor-pointer hover:bg-amber-50 dark:hover:bg-amber-950/20">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="flex items-center justify-between w-full">
<span>
Authentication required to continue ({progress.completed}/
{progress.total} complete)
</span>
<span>Authentication required to continue ({progress.completed}/{progress.total} complete)</span>
<ChevronDown className="h-4 w-4" />
</AlertDescription>
</Alert>
@ -265,9 +236,7 @@ export function ConnectStep() {
<CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
<Github className="h-4 w-4" />
<span className="text-sm font-medium">
Sign in with Clerk
</span>
<span className="text-sm font-medium">Sign in with Clerk</span>
</div>
<div className="scale-90 origin-top-left">
<SignIn routing="hash" />
@ -281,15 +250,9 @@ export function ConnectStep() {
<CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
<Github className="h-4 w-4" />
<span className="text-sm font-medium">
Connect GitHub Account
</span>
<span className="text-sm font-medium">Connect GitHub Account</span>
</div>
<Button
size="sm"
variant="outline"
onClick={() => window.open('/user-profile', '_blank')}
>
<Button size="sm" variant="outline" onClick={() => window.open('/user-profile', '_blank')}>
<ExternalLink className="h-3 w-3 mr-2" />
Connect GitHub
</Button>
@ -302,9 +265,7 @@ export function ConnectStep() {
<CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
<Wallet className="h-4 w-4" />
<span className="text-sm font-medium">
Connect Wallet
</span>
<span className="text-sm font-medium">Connect Wallet</span>
</div>
<Button size="sm" onClick={handleConnectWallet}>
Connect Wallet
@ -313,23 +274,17 @@ export function ConnectStep() {
</Card>
)}
{missing.githubBackendSync &&
!missing.walletConnection &&
!missing.clerkGithub && (
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
<CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
<Github className="h-4 w-4" />
<span className="text-sm font-medium">
Sync GitHub Access
</span>
</div>
<GitHubBackendAuth
onAuthStatusChange={handleGithubAuthChange}
/>
</CardContent>
</Card>
)}
{missing.githubBackendSync && !missing.walletConnection && !missing.clerkGithub && (
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
<CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
<Github className="h-4 w-4" />
<span className="text-sm font-medium">Sync GitHub Access</span>
</div>
<GitHubBackendAuth onAuthStatusChange={handleGithubAuthChange} />
</CardContent>
</Card>
)}
</CollapsibleContent>
</Collapsible>
)}
@ -337,22 +292,20 @@ export function ConnectStep() {
{/* Mode Selection Tabs */}
<div className="grid grid-cols-2 gap-1 p-1 bg-zinc-100 dark:bg-zinc-800 rounded-lg mb-6">
<Button
variant={isImportMode ? 'default' : 'ghost'}
className={`${
isImportMode
? 'bg-white dark:bg-zinc-700 shadow-sm'
: 'bg-transparent hover:bg-white/50 dark:hover:bg-zinc-700/50'
variant={isImportMode ? "default" : "ghost"}
className={`${isImportMode
? 'bg-white dark:bg-zinc-700 shadow-sm'
: 'bg-transparent hover:bg-white/50 dark:hover:bg-zinc-700/50'
}`}
onClick={() => toggleMode('import')}
>
Import a repository
</Button>
<Button
variant={!isImportMode ? 'default' : 'ghost'}
className={`${
!isImportMode
? 'bg-white dark:bg-zinc-700 shadow-sm'
: 'bg-transparent hover:bg-white/50 dark:hover:bg-zinc-700/50'
variant={!isImportMode ? "default" : "ghost"}
className={`${!isImportMode
? 'bg-white dark:bg-zinc-700 shadow-sm'
: 'bg-transparent hover:bg-white/50 dark:hover:bg-zinc-700/50'
}`}
onClick={() => toggleMode('template')}
>
@ -374,8 +327,7 @@ export function ConnectStep() {
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
No repositories found. Make sure your GitHub account has
repositories.
No repositories found. Make sure your GitHub account has repositories.
</AlertDescription>
</Alert>
</div>
@ -394,13 +346,9 @@ export function ConnectStep() {
>
<Github className="h-5 w-5 mr-3 text-zinc-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">
{repo.full_name}
</div>
<div className="font-medium text-sm">{repo.full_name}</div>
{repo.description && (
<div className="text-xs text-zinc-500 truncate">
{repo.description}
</div>
<div className="text-xs text-zinc-500 truncate">{repo.description}</div>
)}
</div>
{selectedRepo === repo.full_name && (
@ -413,10 +361,7 @@ export function ConnectStep() {
{/* Project Name Input for Repository Import */}
{selectedRepo && (
<div className="mt-6 space-y-2">
<Label
htmlFor="projectName"
className="text-sm font-medium"
>
<Label htmlFor="projectName" className="text-sm font-medium">
Project Name
</Label>
<Input
@ -437,46 +382,38 @@ export function ConnectStep() {
) : (
/* Template Selection */
<div className="space-y-4">
{AVAILABLE_TEMPLATES.filter((t) => !t.isComingSoon).map(
(template) => (
<div
key={template.id}
className={`flex items-center p-4 rounded-lg border cursor-pointer transition-all ${
selectedTemplate?.id === template.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-950/20'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600'
}`}
onClick={() => handleTemplateSelect(template)}
>
{/* Template Icon */}
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-zinc-100 dark:bg-zinc-800 mr-4">
<div className="w-6 h-6 bg-zinc-600 dark:bg-zinc-400 rounded flex items-center justify-center text-xs font-bold text-white">
{template.icon === 'web'
? 'PWA'
: template.icon === 'nextjs'
? 'N'
: 'IMG'}
</div>
{AVAILABLE_TEMPLATES.filter(t => !t.isComingSoon).map((template) => (
<div
key={template.id}
className={`flex items-center p-4 rounded-lg border cursor-pointer transition-all ${
selectedTemplate?.id === template.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-950/20'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600'
}`}
onClick={() => handleTemplateSelect(template)}
>
{/* Template Icon */}
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-zinc-100 dark:bg-zinc-800 mr-4">
<div className="w-6 h-6 bg-zinc-600 dark:bg-zinc-400 rounded flex items-center justify-center text-xs font-bold text-white">
{template.icon === 'web' ? 'PWA' : template.icon === 'nextjs' ? 'N' : 'IMG'}
</div>
{/* Template Info */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm mb-1">
{template.name}
</div>
<div className="flex items-center text-xs text-zinc-500">
<Github className="h-3 w-3 mr-1" />
{template.repoFullName}
</div>
</div>
{/* Selection Indicator */}
{selectedTemplate?.id === template.id && (
<CheckCircle2 className="h-5 w-5 text-blue-500 flex-shrink-0" />
)}
</div>
)
)}
{/* Template Info */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm mb-1">{template.name}</div>
<div className="flex items-center text-xs text-zinc-500">
<Github className="h-3 w-3 mr-1" />
{template.repoFullName}
</div>
</div>
{/* Selection Indicator */}
{selectedTemplate?.id === template.id && (
<CheckCircle2 className="h-5 w-5 text-blue-500 flex-shrink-0" />
)}
</div>
))}
{/* Project Name Input for Templates */}
{selectedTemplate && (
@ -506,12 +443,7 @@ export function ConnectStep() {
</Button>
<Button
onClick={handleNext}
disabled={
!isFullyAuthenticated ||
(isImportMode
? !selectedRepo
: !selectedTemplate || !projectName.trim())
}
disabled={!isFullyAuthenticated || (isImportMode ? !selectedRepo : (!selectedTemplate || !projectName.trim()))}
>
Next
</Button>

View File

@ -1,30 +1,19 @@
// src/components/onboarding/deploy-step/deploy-step.tsx
'use client'
import { useOnboarding } from '@/components/onboarding/useOnboarding'
import { useState, useEffect } from 'react'
import { useTheme } from 'next-themes'
import { Github, Loader2, AlertTriangle, CheckCircle2 } from 'lucide-react'
import { useOnboarding } from '@/components/onboarding/store'
import { useWallet } from '@/context/WalletContext'
import { useDeployment } from '@/hooks/useDeployment'
import { useTemplateDeployment } from '@/hooks/useTemplate'
import { Alert, AlertDescription } from '@workspace/ui/components/alert'
import { Badge } from '@workspace/ui/components/badge'
import { Button } from '@workspace/ui/components/button'
import {
Card,
CardContent,
CardHeader,
CardTitle
} from '@workspace/ui/components/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle
} from '@workspace/ui/components/dialog'
import { Progress } from '@workspace/ui/components/progress'
import { AlertTriangle, CheckCircle2, Github, Loader2 } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogFooter } from '@workspace/ui/components/dialog'
import { Alert, AlertDescription } from '@workspace/ui/components/alert'
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card'
import { Badge } from '@workspace/ui/components/badge'
import { toast } from 'sonner'
export function DeployStep() {
@ -40,8 +29,7 @@ export function DeployStep() {
// Contexts and hooks
const { wallet } = useWallet()
const { deployRepository, isDeploying: isRepoDeploying } = useDeployment()
const { deployTemplate, isDeploying: isTemplateDeploying } =
useTemplateDeployment()
const { deployTemplate, isDeploying: isTemplateDeploying } = useTemplateDeployment()
// Determine deployment type and get the right deploying state
const isTemplateMode = formData.deploymentMode === 'template'
@ -65,10 +53,7 @@ export function DeployStep() {
return {
name: formData.githubRepo?.split('/').pop() || 'Repository',
source: formData.githubRepo || 'Unknown Repository',
projectName:
formData.projectName ||
formData.githubRepo?.split('/').pop() ||
'New Project',
projectName: formData.projectName || formData.githubRepo?.split('/').pop() || 'New Project',
type: 'Repository'
}
}
@ -120,9 +105,7 @@ export function DeployStep() {
}
} catch (error) {
console.error('Deployment failed:', error)
setDeploymentError(
error instanceof Error ? error.message : 'Deployment failed'
)
setDeploymentError(error instanceof Error ? error.message : 'Deployment failed')
}
}
@ -142,8 +125,8 @@ export function DeployStep() {
organizationSlug: formData.selectedOrg,
environmentVariables: formData.environmentVariables || [],
deployerLrn: formData.selectedLrn
}
const result = await deployTemplate(config)
};
const result = await deployTemplate(config);
// Save deployment results
setFormData({
@ -173,10 +156,7 @@ export function DeployStep() {
organizationSlug: formData.selectedOrg,
repository: formData.githubRepo,
branch: 'main', // Default branch
name:
formData.projectName ||
formData.githubRepo.split('/').pop() ||
'New Project',
name: formData.projectName || formData.githubRepo.split('/').pop() || 'New Project',
environmentVariables: formData.environmentVariables || []
}
@ -214,66 +194,18 @@ export function DeployStep() {
<div className="max-w-md w-full mx-auto">
{/* Deploy icon */}
<div className="mx-auto mb-6 flex justify-center">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={isDarkMode ? 'text-white' : 'text-black'}
>
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline
points="7.5 4.21 12 6.81 16.5 4.21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline
points="7.5 19.79 7.5 14.6 3 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline
points="21 12 16.5 14.6 16.5 19.79"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline
points="3.27 6.96 12 12.01 20.73 6.96"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<line
x1="12"
y1="22.08"
x2="12"
y2="12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className={isDarkMode ? "text-white" : "text-black"}>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<polyline points="7.5 4.21 12 6.81 16.5 4.21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<polyline points="7.5 19.79 7.5 14.6 3 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<polyline points="21 12 16.5 14.6 16.5 19.79" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="12" y1="22.08" x2="12" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
{/* Deploy header */}
<h2
className={`text-2xl font-medium ${isDarkMode ? 'text-white' : 'text-zinc-900'} text-center mb-2`}
>
<h2 className={`text-2xl font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} text-center mb-2`}>
{isDeploying ? 'Deploying...' : 'Deploy'}
</h2>
<p className="text-center text-zinc-500 mb-8">
@ -294,9 +226,7 @@ export function DeployStep() {
<div className="flex items-center gap-3">
<Github className="h-4 w-4 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">
{deploymentInfo.projectName}
</div>
<div className="font-medium text-sm">{deploymentInfo.projectName}</div>
<div className="text-xs text-muted-foreground font-mono">
{deploymentInfo.source}
</div>
@ -310,25 +240,18 @@ export function DeployStep() {
</div>
<div>
<div className="text-muted-foreground">Deployer</div>
<div className="font-medium">
{formData.selectedLrn ? 'LRN' : 'Auction'}
</div>
<div className="font-medium">{formData.selectedLrn ? 'LRN' : 'Auction'}</div>
</div>
</div>
{formData.environmentVariables &&
formData.environmentVariables.length > 0 && (
<div className="pt-2">
<div className="text-xs text-muted-foreground mb-1">
Environment Variables
</div>
<div className="text-xs">
{formData.environmentVariables.length} variable
{formData.environmentVariables.length !== 1 ? 's' : ''}{' '}
configured
</div>
{formData.environmentVariables && formData.environmentVariables.length > 0 && (
<div className="pt-2">
<div className="text-xs text-muted-foreground mb-1">Environment Variables</div>
<div className="text-xs">
{formData.environmentVariables.length} variable{formData.environmentVariables.length !== 1 ? 's' : ''} configured
</div>
)}
</div>
)}
</CardContent>
</Card>
@ -350,8 +273,7 @@ export function DeployStep() {
<AlertDescription>
<div className="font-medium">Deployment Successful!</div>
<div className="text-sm mt-1">
Your project has been deployed successfully. You'll be
redirected to the project dashboard.
Your project has been deployed successfully. You'll be redirected to the project dashboard.
</div>
</AlertDescription>
</Alert>
@ -361,21 +283,13 @@ export function DeployStep() {
{isDeploying && (
<div className="mb-8">
<div className="flex justify-between items-center mb-2">
<div
className={`${isDarkMode ? 'text-white' : 'text-zinc-900'} text-sm`}
>
{isTemplateMode
? 'Creating repository from template...'
: 'Deploying repository...'}
<div className={`${isDarkMode ? "text-white" : "text-zinc-900"} text-sm`}>
{isTemplateMode ? 'Creating repository from template...' : 'Deploying repository...'}
</div>
</div>
<Progress
value={undefined}
className={`h-2 ${isDarkMode ? 'bg-zinc-800' : 'bg-zinc-200'}`}
/>
<Progress value={undefined} className={`h-2 ${isDarkMode ? "bg-zinc-800" : "bg-zinc-200"}`} />
<div className="text-xs text-muted-foreground mt-2">
This process may take several minutes. Please do not close this
window.
This process may take several minutes. Please do not close this window.
</div>
</div>
)}
@ -384,7 +298,7 @@ export function DeployStep() {
<div className="flex justify-between items-center mt-4">
<Button
variant="outline"
className={`${isDarkMode ? 'text-zinc-400 border-zinc-700' : 'text-zinc-600 border-zinc-300'} bg-transparent`}
className={`${isDarkMode ? "text-zinc-400 border-zinc-700" : "text-zinc-600 border-zinc-300"} bg-transparent`}
onClick={previousStep}
disabled={isDeploying || deploymentSuccess}
>
@ -401,7 +315,7 @@ export function DeployStep() {
</Button>
) : isDeploying ? (
<Button
className={`${isDarkMode ? 'bg-zinc-700 text-zinc-300' : 'bg-zinc-300 text-zinc-600'}`}
className={`${isDarkMode ? "bg-zinc-700 text-zinc-300" : "bg-zinc-300 text-zinc-600"}`}
disabled
>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@ -413,22 +327,9 @@ export function DeployStep() {
onClick={handlePayAndDeploy}
disabled={deploymentError !== null}
>
{formData.deploymentType === 'auction'
? 'Pay and Deploy'
: 'Deploy'}
<svg
className="ml-2 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 12H19M19 12L13 6M19 12L13 18"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
{formData.deploymentType === 'auction' ? 'Pay and Deploy' : 'Deploy'}
<svg className="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12H19M19 12L13 6M19 12L13 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</Button>
)}
@ -459,9 +360,7 @@ export function DeployStep() {
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Source:</span>
<span className="font-mono text-xs">
{deploymentInfo.source}
</span>
<span className="font-mono text-xs">{deploymentInfo.source}</span>
</div>
</div>
</div>
@ -479,9 +378,7 @@ export function DeployStep() {
<div className="space-y-2">
<h3 className="text-sm font-medium">Deployer</h3>
<div className="text-sm">
<div className="font-mono text-xs">
{formData.selectedLrn}
</div>
<div className="font-mono text-xs">{formData.selectedLrn}</div>
</div>
</div>
)}
@ -505,10 +402,17 @@ export function DeployStep() {
</div>
<DialogFooter className="flex justify-end space-x-2">
<Button variant="outline" onClick={handleCancelConfirm}>
<Button
variant="outline"
onClick={handleCancelConfirm}
>
Cancel
</Button>
<Button onClick={handleConfirmDeploy}>Confirm Deployment</Button>
<Button
onClick={handleConfirmDeploy}
>
Confirm Deployment
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -10,7 +10,9 @@
// Main component
export { default as Onboarding } from './Onboarding'
export { default as OnboardingDialog } from './OnboardingDialog'
export {
default as OnboardingDialog,
} from './OnboardingDialog'
// Step components
export { ConfigureStep } from './configure-step'
@ -25,7 +27,7 @@ export * from './common'
export * from './sidebar'
// Store and hooks
export { useOnboarding } from './useOnboarding'
export { useOnboarding } from './store'
// Types
export * from './types'

View File

@ -7,7 +7,7 @@
/**
* Available steps in the onboarding flow
*/
export type Step = 'connect' | 'configure' | 'deploy' | 'success'
export type Step = 'connect' | 'configure' | 'deploy'
/**
* Form data collected during the onboarding process

View File

@ -19,7 +19,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
const initGQLClient = async () => {
try {
const client = new GQLClient({
gqlEndpoint: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/graphql`,
gqlEndpoint: 'http://localhost:8000/graphql',
})
setGqlClient(client)
} catch (error) {

View File

@ -11,8 +11,6 @@ import {
useCallback
} from 'react'
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
/**
* @interface BackendContextType
* @description Defines the structure of the BackendContext value.
@ -47,7 +45,7 @@ export const BackendProvider: React.FC<{ children: ReactNode }> = ({
// Check backend connection
const checkBackendConnection = useCallback(async (): Promise<void> => {
try {
const response = await fetch(`${BACKEND_URL}/auth/session`, {
const response = await fetch('http://localhost:8000/auth/session', {
method: 'GET',
credentials: 'include',
})

View File

@ -1,3 +0,0 @@
REGISTRY_BOND_ID=
DEPLOYER_LRN=
AUTHORITY=

View File

@ -1,9 +1,5 @@
# deployer
The following steps are for deploying the frontend app to vaasl webapp deployer.
Run these steps in the `apps/deployer/` directory:
- Install dependencies
```bash
@ -11,60 +7,46 @@ Run these steps in the `apps/deployer/` directory:
```
```bash
# if you do not have jq installed already
brew install jq
# OR
sudo apt-get install jq
brew install jq # if you do not have jq installed already
```
- Copy and update `.env`
- Run script to deploy app
```
cp .env.example .env
```
- To deploy frontend app to `dashboard.staging.apps.snowballtools.com`
Set the required variables:
```bash
./deploy-frontend.staging.sh
```
```
REGISTRY_BOND_ID=230cfedda15e78edc8986dfcb870e1b618f65c56e38d2735476d2a8cb3f25e38
DEPLOYER_LRN=lrn://vaasl-provider/deployers/webapp-deployer-api.apps.vaasl.io
AUTHORITY=laconic
```
- To deploy frontend app to `dashboard.apps.snowballtools.com`
- Set the `userKey` in [config.yml](./config.yml)
Note: The `userKey` should be the private key of the account owning the bond set in `config.yml` (account address: `laconic13maulvmjxnyx3g855vk0lsv5aptf3rpxskynef`)
- Set the app env values in [deploy-frontend.sh](./deploy-frontend.sh) (Refer to main [readme](/readme.md#3-configure-environment-variables)):
<!-- TODO: Set env values from a file -->
```sh
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: your_clerk_key
CLERK_SECRET_KEY: your_clerk_secret
NEXT_PUBLIC_WALLET_IFRAME_URL: https://wallet.laconic.com
NEXT_PUBLIC_LACONICD_CHAIN_ID: laconic-mainnet
NEXT_PUBLIC_API_URL: https://deploy-backend.apps.vaasl.io
NEXT_PUBLIC_GITHUB_FALLBACK_TOKEN: your_github_token
```
NOTE: Replace `your_clerk_key`, `your_clerk_secret` and `your_github_token` with actual values
- Run script to deploy app to `deploy-staging.laconic.co`
```bash
./deploy-frontend.sh
```
```bash
./deploy-frontend.sh
```
- Commit the updated [ApplicationRecord](records/application-record.yml) and [ApplicationDeploymentRequest](records/application-deployment-request.yml) files to the repository
## Notes
- Any config env can be updated in [records/application-deployment-request.yml](records/application-deployment-request.yml)
```yml
record:
...
config:
env:
LACONIC_HOSTED_CONFIG_app_server_url: https://snowballtools-base-api-001.apps.snowballtools.com
...
```
- On changing `LACONIC_HOSTED_CONFIG_app_github_clientid`, the GitHub client ID and secret need to be changed in backend config too
## Troubleshoot
- Check deployment status in [web-app deployer](https://console.laconic.com/deployer).
- Check records in [registry console app](https://console.laconic.com/#/registry).
- If deployment fails due to low bond balance
- Check balances
```bash
@ -72,11 +54,11 @@ Run these steps in the `apps/deployer/` directory:
pnpm laconic registry account get
# Bond balance
pnpm laconic registry bond get --id 230cfedda15e78edc8986dfcb870e1b618f65c56e38d2735476d2a8cb3f25e38
pnpm laconic registry bond get --id 99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32
```
- Command to refill bond
```bash
pnpm laconic registry bond refill --id 230cfedda15e78edc8986dfcb870e1b618f65c56e38d2735476d2a8cb3f25e38 --type alnt --quantity 10000000
pnpm laconic registry bond refill --id 99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32 --type alnt --quantity 10000000
```

View File

@ -1,8 +1,8 @@
services:
registry:
rpcEndpoint: https://laconicd-mainnet-1.laconic.com/
gqlEndpoint: https://laconicd-mainnet-1.laconic.com/api
rpcEndpoint: https://laconicd-sapo.laconic.com
gqlEndpoint: https://laconicd-sapo.laconic.com/api
userKey:
bondId: 230cfedda15e78edc8986dfcb870e1b618f65c56e38d2735476d2a8cb3f25e38
chainId: laconic-mainnet
bondId:
chainId: laconic-testnet-2
gasPrice: 0.001alnt

View File

@ -6,7 +6,7 @@ echo "Using DEPLOYER_LRN: $DEPLOYER_LRN"
echo "Using AUTHORITY: $AUTHORITY"
# Repository URL
REPO_URL="https://git.vdb.to/NasSharaf/laconic-deployer-frontend"
REPO_URL="https://git.vdb.to/cerc-io/snowballtools-base"
# Get the latest commit hash for a branch
BRANCH_NAME="main"
@ -25,7 +25,7 @@ CONFIG_FILE=config.yml
# Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts
# Get latest version from registry and increment application-record version
NEW_APPLICATION_VERSION=$(pnpm --silent laconic -c $CONFIG_FILE registry record list --type ApplicationRecord --all --name "laconic-deployer-frontend" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
NEW_APPLICATION_VERSION=$(pnpm --silent laconic -c $CONFIG_FILE registry record list --type ApplicationRecord --all --name "deploy-frontend" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then
# Set application-record version if no previous records were found
@ -39,8 +39,8 @@ record:
version: $NEW_APPLICATION_VERSION
repository_ref: $LATEST_HASH
repository: ["$REPO_URL"]
app_type: webapp/next
name: laconic-deployer-frontend
app_type: webapp
name: deploy-frontend
app_version: $PACKAGE_VERSION
EOF
@ -60,7 +60,7 @@ echo "ApplicationRecord published"
echo $RECORD_ID
# Set name to record
REGISTRY_APP_LRN="lrn://$AUTHORITY/applications/laconic-deployer-frontend"
REGISTRY_APP_LRN="lrn://$AUTHORITY/applications/deploy-frontend"
sleep 2
pnpm --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN@${PACKAGE_VERSION}" "$RECORD_ID"
@ -122,18 +122,19 @@ cat >./records/application-deployment-request.yml <<EOF
record:
type: ApplicationDeploymentRequest
version: '1.0.0'
name: laconic-deployer-frontend@$PACKAGE_VERSION
application: lrn://$AUTHORITY/applications/laconic-deployer-frontend@$PACKAGE_VERSION
name: deploy-frontend@$PACKAGE_VERSION
application: lrn://$AUTHORITY/applications/deploy-frontend@$PACKAGE_VERSION
deployer: $DEPLOYER_LRN
dns: deploy-staging.laconic.com
dns: deploy.laconic.com
config:
env:
NEXT_PUBLIC_WALLET_IFRAME_URL: https://wallet.laconic.com
NEXT_PUBLIC_LACONICD_CHAIN_ID: laconic-mainnet
NEXT_PUBLIC_API_URL: https://deploy-backend.apps.vaasl.io
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
CLERK_SECRET_KEY:
NEXT_PUBLIC_GITHUB_FALLBACK_TOKEN: your_github_token
LACONIC_HOSTED_CONFIG_server_url: https://deploy-backend.laconic.com
LACONIC_HOSTED_CONFIG_github_clientid: Ov23li4NtYybQlF6u5Dk
LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: laconic-templates/test-progressive-web-app
LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: laconic-templates/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_github_next_app_templaterepo: laconic-templates/starter.nextjs-react-tailwind
LACONIC_HOSTED_CONFIG_laconicd_chain_id: laconic-testnet-2
LACONIC_HOSTED_CONFIG_wallet_iframe_url: https://wallet.laconic.com
meta:
note: Added @ $CURRENT_DATE_TIME
repository: "$REPO_URL"
@ -141,12 +142,6 @@ record:
payment: $txHash
EOF
# Old env values for frontend app
# LACONIC_HOSTED_CONFIG_github_clientid: Ov23li4NtYybQlF6u5Dk
# LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: laconic-templates/test-progressive-web-app
# LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: laconic-templates/image-upload-pwa-example
# LACONIC_HOSTED_CONFIG_github_next_app_templaterepo: laconic-templates/starter.nextjs-react-tailwind
RECORD_FILE=records/application-deployment-request.yml
sleep 2

View File

@ -1,16 +0,0 @@
#!/bin/bash
PKG_DIR="./apps/deploy-fe"
cat > $PKG_DIR/.env <<EOF
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=CERC_RUNTIME_ENV_NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
CLERK_SECRET_KEY=CERC_RUNTIME_ENV_CLERK_SECRET_KEY
NEXT_PUBLIC_WALLET_IFRAME_URL=CERC_RUNTIME_ENV_NEXT_PUBLIC_WALLET_IFRAME_URL
NEXT_PUBLIC_LACONICD_CHAIN_ID=CERC_RUNTIME_ENV_NEXT_PUBLIC_LACONICD_CHAIN_ID
NEXT_PUBLIC_API_URL=CERC_RUNTIME_ENV_NEXT_PUBLIC_API_URL
NEXT_PUBLIC_GITHUB_FALLBACK_TOKEN=CERC_RUNTIME_ENV_NEXT_PUBLIC_GITHUB_FALLBACK_TOKEN
EOF
pnpm install || exit 1
pnpm build:fe-compile || exit 1

View File

@ -5,7 +5,6 @@
"main": "index.js",
"scripts": {
"build": "turbo build",
"build:fe-compile": "turbo build:compile --filter=deploy-fe",
"dev": "turbo dev",
"lint": "turbo lint",
"start": "turbo start",

2073
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -41,7 +41,7 @@ Create a `.env.local` file in the `apps/deploy-fe` directory:
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_key
CLERK_SECRET_KEY=your_clerk_secret
NEXT_PUBLIC_WALLET_IFRAME_URL=http://localhost:4000
NEXT_PUBLIC_LACONICD_CHAIN_ID=laconic-mainnet
NEXT_PUBLIC_LACONICD_CHAIN_ID=laconic-testnet-2
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_GITHUB_FALLBACK_TOKEN=your_github_token
```
@ -93,25 +93,3 @@ Make sure you have:
1. Connected your GitHub account in Clerk
2. Provided the necessary permissions for repository access
3. Set a fallback GitHub token in `.env.local` if needed for development
## Test deployment
- Follow the dev install steps for stack-orchestrator from <https://git.vdb.to/cerc-io/stack-orchestrator/src/branch/main/docs/CONTRIBUTING.md#install>
- Build the container for app
```bash
laconic-so build-webapp --source-repo <path-to-app-repo>/laconic-deployer-frontend --base-container cerc/nextjs-base
```
- Create an env file as described in [previous steps](#3-configure-environment-variables)
- Run the webapp container locally
```bash
docker run -p 3000:80 --env-file <path-to-env-file> cerc/laconic-deployer-frontend:local
```
- Scripts used for deployment
- App container build : [build-webapp.sh](./build-webapp.sh)
- Run app container : [run-webapp.sh](./run-webapp.sh)

View File

@ -1,17 +0,0 @@
#!/usr/bin/env bash
if [ -n "$CERC_SCRIPT_DEBUG" ]; then
set -x
fi
SCRIPT_DIR=/scripts/
PKG_DIR="./apps/deploy-fe"
cd $PKG_DIR
"$SCRIPT_DIR/apply-runtime-env.sh" "`pwd`" .next .next-r
mv .next .next.old
mv .next-r/.next .
pnpm build:generate || exit 1
pnpm start -p ${CERC_LISTEN_PORT:-80}

View File

@ -8,11 +8,6 @@
"outputs": [".next/**", "!.next/cache/**"],
"env": ["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", "CLERK_SECRET_KEY"]
},
"build:compile": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$"],
"outputs": ["dist/**"]
},
"start": {
"dependsOn": ["^build"],
"cache": false,